@aion0/forge 0.4.2 → 0.4.4

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.
@@ -8,6 +8,8 @@ interface WorkflowNode {
8
8
  id: string;
9
9
  project: string;
10
10
  prompt: string;
11
+ mode?: 'claude' | 'shell';
12
+ branch?: string;
11
13
  dependsOn: string[];
12
14
  outputs: { name: string; extract: string }[];
13
15
  routes: { condition: string; next: string }[];
@@ -65,22 +67,29 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
65
67
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
66
68
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
67
69
  const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
70
+ const [activeWorkflow, setActiveWorkflow] = useState<string | null>(null); // selected workflow in left panel
68
71
  const [showCreate, setShowCreate] = useState(false);
69
72
  const [selectedWorkflow, setSelectedWorkflow] = useState<string>('');
73
+ const [projects, setProjects] = useState<{ name: string; path: string }[]>([]);
70
74
  const [inputValues, setInputValues] = useState<Record<string, string>>({});
71
75
  const [creating, setCreating] = useState(false);
72
76
  const [showEditor, setShowEditor] = useState(false);
73
77
  const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
78
+ const [showImport, setShowImport] = useState(false);
79
+ const [importYaml, setImportYaml] = useState('');
74
80
 
75
81
  const fetchData = useCallback(async () => {
76
- const [pRes, wRes] = await Promise.all([
82
+ const [pRes, wRes, projRes] = await Promise.all([
77
83
  fetch('/api/pipelines'),
78
84
  fetch('/api/pipelines?type=workflows'),
85
+ fetch('/api/projects'),
79
86
  ]);
80
87
  const pData = await pRes.json();
81
88
  const wData = await wRes.json();
89
+ const projData = await projRes.json();
82
90
  if (Array.isArray(pData)) setPipelines(pData);
83
91
  if (Array.isArray(wData)) setWorkflows(wData);
92
+ if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
84
93
  }, []);
85
94
 
86
95
  useEffect(() => {
@@ -148,37 +157,62 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
148
157
 
149
158
  return (
150
159
  <div className="flex-1 flex min-h-0">
151
- {/* Left — Pipeline list */}
152
- <aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
153
- <div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
154
- <span className="text-[11px] font-semibold text-[var(--text-primary)]">Pipelines</span>
155
- <select
156
- onChange={async (e) => {
157
- const name = e.target.value;
158
- if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
159
- try {
160
- const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
161
- const data = await res.json();
162
- setEditorYaml(data.yaml || undefined);
163
- } catch { setEditorYaml(undefined); }
164
- setShowEditor(true);
165
- e.target.value = '';
166
- }}
167
- className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
168
- defaultValue=""
169
- >
170
- <option value="">Editor ▾</option>
171
- <option value="">+ New workflow</option>
172
- {workflows.map(w => <option key={w.name} value={w.name}>{w.builtin ? '⚙ ' : ''}{w.name}</option>)}
173
- </select>
160
+ {/* Left — Workflow list */}
161
+ <aside className="w-64 border-r border-[var(--border)] flex flex-col shrink-0">
162
+ <div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-1.5">
163
+ <span className="text-[11px] font-semibold text-[var(--text-primary)] flex-1">Workflows</span>
174
164
  <button
175
- onClick={() => setShowCreate(v => !v)}
176
- className={`text-[10px] px-2 py-0.5 rounded ${showCreate ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
177
- >
178
- + Run
179
- </button>
165
+ onClick={() => setShowImport(v => !v)}
166
+ className="text-[9px] text-green-400 hover:underline"
167
+ >Import</button>
168
+ <button
169
+ onClick={() => { setEditorYaml(undefined); setShowEditor(true); }}
170
+ className="text-[9px] text-[var(--accent)] hover:underline"
171
+ >+ New</button>
180
172
  </div>
181
173
 
174
+ {/* Import form */}
175
+ {showImport && (
176
+ <div className="p-3 border-b border-[var(--border)] space-y-2">
177
+ <textarea
178
+ value={importYaml}
179
+ onChange={e => setImportYaml(e.target.value)}
180
+ placeholder="Paste YAML workflow here..."
181
+ className="w-full h-40 text-xs font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)]"
182
+ spellCheck={false}
183
+ />
184
+ <div className="flex gap-2">
185
+ <button
186
+ onClick={async () => {
187
+ if (!importYaml.trim()) return;
188
+ try {
189
+ const res = await fetch('/api/pipelines', {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify({ action: 'save-workflow', yaml: importYaml }),
193
+ });
194
+ const data = await res.json();
195
+ if (data.ok) {
196
+ setShowImport(false);
197
+ setImportYaml('');
198
+ fetchData();
199
+ alert(`Workflow "${data.name}" imported successfully`);
200
+ } else {
201
+ alert(`Import failed: ${data.error}`);
202
+ }
203
+ } catch { alert('Import failed'); }
204
+ }}
205
+ disabled={!importYaml.trim()}
206
+ className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
207
+ >Save Workflow</button>
208
+ <button
209
+ onClick={() => { setShowImport(false); setImportYaml(''); }}
210
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
211
+ >Cancel</button>
212
+ </div>
213
+ </div>
214
+ )}
215
+
182
216
  {/* Create form */}
183
217
  {showCreate && (
184
218
  <div className="p-3 border-b border-[var(--border)] space-y-2">
@@ -193,17 +227,28 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
193
227
  ))}
194
228
  </select>
195
229
 
196
- {/* Input fields */}
230
+ {/* Input fields — project fields get a dropdown */}
197
231
  {currentWorkflow && Object.keys(currentWorkflow.input).length > 0 && (
198
232
  <div className="space-y-1.5">
199
233
  {Object.entries(currentWorkflow.input).map(([key, desc]) => (
200
234
  <div key={key}>
201
235
  <label className="text-[9px] text-[var(--text-secondary)]">{key}: {desc}</label>
202
- <input
203
- value={inputValues[key] || ''}
204
- onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
205
- className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
206
- />
236
+ {key.toLowerCase() === 'project' ? (
237
+ <select
238
+ value={inputValues[key] || ''}
239
+ onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
240
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)]"
241
+ >
242
+ <option value="">Select project...</option>
243
+ {projects.map(p => <option key={p.path} value={p.name}>{p.name}</option>)}
244
+ </select>
245
+ ) : (
246
+ <input
247
+ value={inputValues[key] || ''}
248
+ onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
249
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
250
+ />
251
+ )}
207
252
  </div>
208
253
  ))}
209
254
  </div>
@@ -238,44 +283,111 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
238
283
  </div>
239
284
  )}
240
285
 
241
- {/* Pipeline list */}
286
+ {/* Workflow list + execution history */}
242
287
  <div className="flex-1 overflow-y-auto">
243
- {pipelines.map(p => (
244
- <button
245
- key={p.id}
246
- onClick={() => setSelectedPipeline(p)}
247
- className={`w-full text-left px-3 py-2 border-b border-[var(--border)]/30 hover:bg-[var(--bg-tertiary)] ${
248
- selectedPipeline?.id === p.id ? 'bg-[var(--bg-tertiary)]' : ''
249
- }`}
250
- >
251
- <div className="flex items-center gap-2">
252
- <span className={`text-[10px] ${STATUS_COLOR[p.status]}`}>●</span>
253
- <span className="text-xs font-medium truncate">{p.workflowName}</span>
254
- <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{p.id}</span>
255
- </div>
256
- <div className="flex items-center gap-1 mt-0.5 pl-4">
257
- {p.nodeOrder.map(nodeId => (
258
- <span key={nodeId} className={`text-[9px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
259
- {STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
260
- </span>
261
- ))}
262
- <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
263
- {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
264
- </span>
288
+ {workflows.map(w => {
289
+ const isActive = activeWorkflow === w.name;
290
+ const runs = pipelines.filter(p => p.workflowName === w.name);
291
+ return (
292
+ <div key={w.name}>
293
+ <div
294
+ onClick={() => { setActiveWorkflow(isActive ? null : w.name); setSelectedPipeline(null); }}
295
+ className={`w-full text-left px-3 py-2 border-b border-[var(--border)]/30 flex items-center gap-2 cursor-pointer ${
296
+ isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
297
+ }`}
298
+ >
299
+ <span className="text-[8px] text-[var(--text-secondary)]">{isActive ? '▾' : '▸'}</span>
300
+ {w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
301
+ <span className="text-[11px] text-[var(--text-primary)] truncate flex-1">{w.name}</span>
302
+ {runs.length > 0 && <span className="text-[8px] text-[var(--text-secondary)]">{runs.length}</span>}
303
+ <button
304
+ onClick={async (e) => {
305
+ e.stopPropagation();
306
+ setSelectedWorkflow(w.name);
307
+ setInputValues({});
308
+ setShowCreate(true);
309
+ setActiveWorkflow(w.name);
310
+ }}
311
+ className="text-[8px] text-[var(--accent)] hover:underline shrink-0"
312
+ title="Run this workflow"
313
+ >Run</button>
314
+ <button
315
+ onClick={async (e) => {
316
+ e.stopPropagation();
317
+ try {
318
+ const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
319
+ const data = await res.json();
320
+ setEditorYaml(data.yaml || undefined);
321
+ } catch { setEditorYaml(undefined); }
322
+ setShowEditor(true);
323
+ }}
324
+ className="text-[8px] text-green-400 hover:underline shrink-0"
325
+ title={w.builtin ? 'View YAML' : 'Edit'}
326
+ >{w.builtin ? 'View' : 'Edit'}</button>
327
+ </div>
328
+ {/* Execution history for this workflow */}
329
+ {isActive && (
330
+ <div className="bg-[var(--bg-tertiary)]/50">
331
+ {runs.length === 0 ? (
332
+ <div className="px-4 py-2 text-[9px] text-[var(--text-secondary)]">No runs yet</div>
333
+ ) : (
334
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20).map(p => (
335
+ <button
336
+ key={p.id}
337
+ onClick={() => setSelectedPipeline(p)}
338
+ className={`w-full text-left px-4 py-1.5 border-b border-[var(--border)]/20 hover:bg-[var(--bg-tertiary)] ${
339
+ selectedPipeline?.id === p.id ? 'bg-[var(--accent)]/5' : ''
340
+ }`}
341
+ >
342
+ <div className="flex items-center gap-1.5">
343
+ <span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
344
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
345
+ <div className="flex gap-0.5 ml-1">
346
+ {p.nodeOrder.map(nodeId => (
347
+ <span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
348
+ {STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
349
+ </span>
350
+ ))}
351
+ </div>
352
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
353
+ {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
354
+ </span>
355
+ </div>
356
+ </button>
357
+ ))
358
+ )}
359
+ </div>
360
+ )}
265
361
  </div>
266
- </button>
267
- ))}
268
- {pipelines.length === 0 && (
362
+ );
363
+ })}
364
+ {workflows.length === 0 && (
269
365
  <div className="p-4 text-center text-xs text-[var(--text-secondary)]">
270
- No pipelines yet
366
+ No workflows. Click Import or + New to create one.
271
367
  </div>
272
368
  )}
273
369
  </div>
274
370
  </aside>
275
371
 
276
- {/* Right — Pipeline detail */}
277
- <main className="flex-1 flex flex-col min-w-0 overflow-y-auto">
278
- {selectedPipeline ? (
372
+ {/* Right — Pipeline detail / Editor */}
373
+ <main className="flex-1 flex flex-col min-w-0 overflow-hidden">
374
+ {showEditor ? (
375
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
376
+ <PipelineEditor
377
+ initialYaml={editorYaml}
378
+ onSave={async (yaml) => {
379
+ await fetch('/api/pipelines', {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/json' },
382
+ body: JSON.stringify({ action: 'save-workflow', yaml }),
383
+ });
384
+ setShowEditor(false);
385
+ fetchData();
386
+ }}
387
+ onClose={() => setShowEditor(false)}
388
+ />
389
+ </Suspense>
390
+ ) : selectedPipeline ? (
279
391
  <>
280
392
  {/* Header */}
281
393
  <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
@@ -383,74 +495,87 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
383
495
  })}
384
496
  </div>
385
497
  </>
386
- ) : (
387
- <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
388
- <div className="text-center space-y-2">
389
- <p className="text-sm">Select a pipeline or create a new one</p>
390
- <p className="text-xs">Define workflows in <code className="text-[var(--accent)]">~/.forge/flows/*.yaml</code></p>
391
- <details className="text-left text-[10px] mt-4 max-w-md">
392
- <summary className="cursor-pointer text-[var(--accent)]">Example workflow YAML</summary>
393
- <pre className="mt-2 p-3 bg-[var(--bg-tertiary)] rounded overflow-auto whitespace-pre text-[var(--text-secondary)]">{`name: feature-build
394
- description: "Design Implement → Review"
395
-
396
- input:
397
- requirement: "Feature description"
398
-
399
- vars:
400
- project: my-app
401
-
402
- nodes:
403
- architect:
404
- project: "{{vars.project}}"
405
- prompt: |
406
- Analyze this requirement and create
407
- a technical design document:
408
- {{input.requirement}}
409
- outputs:
410
- - name: design_doc
411
- extract: result
412
-
413
- implement:
414
- project: "{{vars.project}}"
415
- depends_on: [architect]
416
- prompt: |
417
- Implement this design:
418
- {{nodes.architect.outputs.design_doc}}
419
- outputs:
420
- - name: diff
421
- extract: git_diff
498
+ ) : activeWorkflow ? (() => {
499
+ const w = workflows.find(wf => wf.name === activeWorkflow);
500
+ if (!w) return null;
501
+ const nodeEntries = Object.entries(w.nodes);
502
+ return (
503
+ <div className="flex-1 flex flex-col overflow-y-auto">
504
+ {/* Workflow header */}
505
+ <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
506
+ <div className="flex items-center gap-2">
507
+ <span className="text-sm font-semibold text-[var(--text-primary)]">{w.name}</span>
508
+ {w.builtin && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
509
+ <div className="ml-auto flex gap-2">
510
+ <button
511
+ onClick={() => { setSelectedWorkflow(w.name); setInputValues({}); setShowCreate(true); }}
512
+ className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
513
+ >Run</button>
514
+ <button
515
+ onClick={async () => {
516
+ try {
517
+ const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
518
+ const data = await res.json();
519
+ setEditorYaml(data.yaml || undefined);
520
+ } catch { setEditorYaml(undefined); }
521
+ setShowEditor(true);
522
+ }}
523
+ className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
524
+ >{w.builtin ? 'View YAML' : 'Edit'}</button>
525
+ </div>
526
+ </div>
527
+ {w.description && <p className="text-[10px] text-[var(--text-secondary)] mt-1">{w.description}</p>}
528
+ {Object.keys(w.input).length > 0 && (
529
+ <div className="mt-2 flex flex-wrap gap-1">
530
+ {Object.entries(w.input).map(([k, v]) => (
531
+ <span key={k} className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]">{k}</span>
532
+ ))}
533
+ </div>
534
+ )}
535
+ </div>
422
536
 
423
- review:
424
- project: "{{vars.project}}"
425
- depends_on: [implement]
426
- prompt: |
427
- Review this code change:
428
- {{nodes.implement.outputs.diff}}`}</pre>
429
- </details>
537
+ {/* Node flow visualization */}
538
+ <div className="p-4 space-y-2">
539
+ {nodeEntries.map(([nodeId, node], i) => (
540
+ <div key={nodeId}>
541
+ {/* Connection line */}
542
+ {i > 0 && (
543
+ <div className="flex items-center justify-center py-1">
544
+ <div className="w-px h-4 bg-[var(--border)]" />
545
+ </div>
546
+ )}
547
+ {/* Node card */}
548
+ <div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]">
549
+ <div className="flex items-center gap-2">
550
+ <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${
551
+ node.mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
552
+ }`}>{node.mode === 'shell' ? 'shell' : 'claude'}</span>
553
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">{nodeId}</span>
554
+ {node.project && <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.project}</span>}
555
+ </div>
556
+ {node.dependsOn.length > 0 && (
557
+ <div className="text-[8px] text-[var(--text-secondary)] mt-1">depends: {node.dependsOn.join(', ')}</div>
558
+ )}
559
+ <p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-2">{node.prompt.slice(0, 120)}{node.prompt.length > 120 ? '...' : ''}</p>
560
+ {node.outputs.length > 0 && (
561
+ <div className="flex gap-1 mt-1">
562
+ {node.outputs.map(o => (
563
+ <span key={o.name} className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">{o.name}</span>
564
+ ))}
565
+ </div>
566
+ )}
567
+ </div>
568
+ </div>
569
+ ))}
570
+ </div>
430
571
  </div>
572
+ );
573
+ })() : (
574
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
575
+ <p className="text-xs">Select a workflow to view details or run</p>
431
576
  </div>
432
577
  )}
433
578
  </main>
434
-
435
- {/* Visual Editor */}
436
- {showEditor && (
437
- <Suspense fallback={null}>
438
- <PipelineEditor
439
- initialYaml={editorYaml}
440
- onSave={async (yaml) => {
441
- // Save YAML to ~/.forge/flows/
442
- await fetch('/api/pipelines', {
443
- method: 'POST',
444
- headers: { 'Content-Type': 'application/json' },
445
- body: JSON.stringify({ action: 'save-workflow', yaml }),
446
- });
447
- setShowEditor(false);
448
- fetchData();
449
- }}
450
- onClose={() => setShowEditor(false)}
451
- />
452
- </Suspense>
453
- )}
454
579
  </div>
455
580
  );
456
581
  }