@aion0/forge 0.9.1 → 0.9.2

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.
Files changed (70) hide show
  1. package/RELEASE_NOTES.md +60 -5
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +106 -0
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. package/lib/help-docs/22-recipes.md +0 -124
package/cli/mw.ts CHANGED
@@ -420,6 +420,32 @@ async function main() {
420
420
  break;
421
421
  }
422
422
 
423
+ case 'pipeline': {
424
+ const sub = args[0];
425
+ if (sub === 'gc') {
426
+ const dryRun = args.includes('--dry-run') || args.includes('-n');
427
+ const res = await api('/api/pipelines/gc', {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({ dry_run: dryRun }),
431
+ });
432
+ if (!res.ok) { console.error('error:', res.error); process.exit(1); }
433
+ console.log(`Scanned: ${res.scanned} pipeline tmp dir(s)`);
434
+ console.log(`${dryRun ? 'Would remove' : 'Removed'}: ${res.removed.length}`);
435
+ for (const r of res.removed) console.log(` - ${r.path} (${r.reason})`);
436
+ if (res.kept.length) {
437
+ console.log(`Kept: ${res.kept.length}`);
438
+ for (const k of res.kept.slice(0, 10)) console.log(` - ${k.path} (${k.reason})`);
439
+ if (res.kept.length > 10) console.log(` … ${res.kept.length - 10} more`);
440
+ }
441
+ } else {
442
+ console.log('Usage: forge pipeline gc [--dry-run]');
443
+ console.log(' Sweeps expired <project>/worktrees/pipeline-<id>/ scratch dirs');
444
+ console.log(' per settings.pipelineTmpKeep{Failed,Cancelled}Days.');
445
+ }
446
+ break;
447
+ }
448
+
423
449
  case 'server': {
424
450
  // Delegate to forge-server.mjs
425
451
  const { execSync } = await import('node:child_process');
@@ -247,6 +247,31 @@ export default function ConnectorsPanel() {
247
247
  } finally { setTesting(false); }
248
248
  }
249
249
 
250
+ // Sync the Forge-stored token into the local `glab` CLI config so
251
+ // user's shell `glab` uses the same token as Forge pipelines. Avoids
252
+ // the "shell glab says 401 but Forge connector says OK" split-brain.
253
+ // gitlab-only for now; other CLIs can be added in the backend route.
254
+ const [syncingCli, setSyncingCli] = useState(false);
255
+ const [syncCliResult, setSyncCliResult] = useState<{ ok: boolean; message?: string; error?: string; hint?: string } | null>(null);
256
+
257
+ async function syncCli() {
258
+ if (!selectedId) return;
259
+ setSyncingCli(true); setSyncCliResult(null);
260
+ try {
261
+ // Save first so the server reads the current token (same pattern as Test).
262
+ await fetch(`/api/connectors/${selectedId}/settings`, {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ settings: values }),
266
+ });
267
+ const r = await fetch(`/api/connectors/${selectedId}/sync-cli`, { method: 'POST' });
268
+ const j = await r.json();
269
+ setSyncCliResult(j);
270
+ } catch (e) {
271
+ setSyncCliResult({ ok: false, error: e instanceof Error ? e.message : String(e) });
272
+ } finally { setSyncingCli(false); }
273
+ }
274
+
250
275
  async function saveSettings() {
251
276
  if (!selectedId) return;
252
277
  try {
@@ -558,6 +583,19 @@ export default function ConnectorsPanel() {
558
583
  {testing ? 'Testing…' : 'Test'}
559
584
  </button>
560
585
  )}
586
+ {/* Sync the Forge token into the local `glab` CLI config so
587
+ user's shell `glab` matches what Forge uses for
588
+ pipelines. Only relevant for gitlab right now. */}
589
+ {selectedId === 'gitlab' && (
590
+ <button
591
+ onClick={syncCli}
592
+ disabled={syncingCli}
593
+ title="Write current Forge token into ~/.config/glab-cli/config.yml so shell `glab` uses the same token. Avoids 'shell 401 but Forge OK' split."
594
+ className="text-[10px] px-2.5 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
595
+ >
596
+ {syncingCli ? 'Syncing…' : 'Sync to glab CLI'}
597
+ </button>
598
+ )}
561
599
  {testResult && (
562
600
  <span
563
601
  className={`text-[10px] ${
@@ -569,6 +607,14 @@ export default function ConnectorsPanel() {
569
607
  : `✗ ${testResult.error || `HTTP ${testResult.status}`}`}
570
608
  </span>
571
609
  )}
610
+ {syncCliResult && (
611
+ <span
612
+ className={`text-[10px] ${syncCliResult.ok ? 'text-green-400' : 'text-red-400'}`}
613
+ title={syncCliResult.hint}
614
+ >
615
+ {syncCliResult.ok ? `✓ ${syncCliResult.message}` : `✗ ${syncCliResult.error}`}
616
+ </span>
617
+ )}
572
618
  </div>
573
619
  </div>
574
620
  </div>
@@ -15,6 +15,7 @@ const ProjectManager = lazy(() => import('./ProjectManager'));
15
15
  const BrowserPanel = lazy(() => import('./BrowserPanel'));
16
16
  const PipelineView = lazy(() => import('./PipelineView'));
17
17
  const JobsView = lazy(() => import('./JobsView'));
18
+ const SchedulesView = lazy(() => import('./SchedulesView'));
18
19
  const HelpDialog = lazy(() => import('./HelpDialog'));
19
20
  const LogViewer = lazy(() => import('./LogViewer'));
20
21
  const SkillsPanel = lazy(() => import('./SkillsPanel'));
@@ -98,7 +99,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
98
99
  }
99
100
 
100
101
  export default function Dashboard({ user }: { user: any }) {
101
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
102
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
102
103
 
103
104
  // Honour `?view=<mode>` from the URL so external links (eg the VSCode
104
105
  // extension) can deep-link straight into a section. Only views that have a
@@ -111,7 +112,7 @@ export default function Dashboard({ user }: { user: any }) {
111
112
  if (raw) {
112
113
  const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
113
114
  const v = aliases[raw] || raw;
114
- const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'skills', 'logs', 'usage'];
115
+ const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
115
116
  if (valid.includes(v)) setViewMode(v as any);
116
117
  }
117
118
  // Optional deep-link to a specific pipeline run — used by the extension
@@ -365,15 +366,15 @@ export default function Dashboard({ user }: { user: any }) {
365
366
  Docs
366
367
  </button>
367
368
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
368
- {/* Automation — single button that lights up for tasks/pipelines/jobs;
369
- clicking it lands on whichever sub-view you used last (default Tasks).
370
- The sub-tab strip below the header switches between the three. */}
369
+ {/* Automation — sub-tabs: tasks / pipelines / schedules.
370
+ Jobs is deprecated and hidden from the nav (backend still
371
+ present in case of reversion, but no UI entry point). */}
371
372
  <button
372
373
  onClick={() => {
373
- if (!['tasks', 'pipelines', 'jobs'].includes(viewMode)) setViewMode('jobs');
374
+ if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
374
375
  }}
375
376
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
376
- ['tasks', 'pipelines', 'jobs'].includes(viewMode)
377
+ ['tasks', 'pipelines', 'schedules'].includes(viewMode)
377
378
  ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
378
379
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
379
380
  }`}
@@ -632,10 +633,10 @@ export default function Dashboard({ user }: { user: any }) {
632
633
 
633
634
  {/* Automation secondary toolbar — sub-tabs + context actions.
634
635
  Lives below the main header so it never squishes the top nav. */}
635
- {['tasks', 'pipelines', 'jobs'].includes(viewMode) && (
636
+ {['tasks', 'pipelines', 'schedules'].includes(viewMode) && (
636
637
  <div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-tertiary)]/40">
637
638
  <div className="flex items-center gap-1">
638
- {(['jobs', 'pipelines', 'tasks'] as const).map((m) => (
639
+ {(['schedules', 'pipelines', 'tasks'] as const).map((m) => (
639
640
  <button
640
641
  key={m}
641
642
  onClick={() => setViewMode(m)}
@@ -645,7 +646,7 @@ export default function Dashboard({ user }: { user: any }) {
645
646
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
646
647
  }`}
647
648
  >
648
- {{ tasks: 'Tasks', pipelines: 'Pipelines', jobs: 'Jobs' }[m]}
649
+ {{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules' }[m]}
649
650
  </button>
650
651
  ))}
651
652
  {viewMode === 'tasks' && (
@@ -791,6 +792,18 @@ export default function Dashboard({ user }: { user: any }) {
791
792
  </Suspense>
792
793
  )}
793
794
 
795
+ {/* Schedules — pipeline triggers (new path, complements Jobs) */}
796
+ {viewMode === 'schedules' && (
797
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
798
+ <SchedulesView
799
+ onViewPipeline={(pipelineId) => {
800
+ setPendingPipelineId(pipelineId);
801
+ setViewMode('pipelines');
802
+ }}
803
+ />
804
+ </Suspense>
805
+ )}
806
+
794
807
  {/* Skills */}
795
808
  {viewMode === 'skills' && (
796
809
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -84,6 +84,19 @@ export default function JobsView({ onViewPipeline }: Props) {
84
84
 
85
85
  useEffect(() => { void refresh(); }, [refresh]);
86
86
 
87
+ // While a Job row is expanded, poll its state every 3s so the user
88
+ // sees Dispatched-Pipelines + busy badges update without manual
89
+ // refresh. Stops when the row is collapsed.
90
+ useEffect(() => {
91
+ if (!expandedJobId) return;
92
+ const id = expandedJobId;
93
+ const interval = setInterval(() => {
94
+ void refresh();
95
+ void loadRuns(id);
96
+ }, 3000);
97
+ return () => clearInterval(interval);
98
+ }, [expandedJobId, refresh]);
99
+
87
100
  async function loadRuns(jobId: string) {
88
101
  try {
89
102
  const r = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/runs?limit=20`);
@@ -101,9 +114,50 @@ export default function JobsView({ onViewPipeline }: Props) {
101
114
  async function fireNow(id: string, opts?: { resetDedup?: boolean }) {
102
115
  try {
103
116
  const qs = opts?.resetDedup ? '?reset_dedup=1' : '';
104
- await fetch(`/api/jobs/${encodeURIComponent(id)}/run${qs}`, { method: 'POST' });
105
- await loadRuns(id);
106
- } catch (e) { alert(`Fire failed: ${e instanceof Error ? e.message : String(e)}`); }
117
+ const r = await fetch(`/api/jobs/${encodeURIComponent(id)}/run${qs}`, { method: 'POST' });
118
+ if (!r.ok) {
119
+ const j = await r.json().catch(() => ({}));
120
+ setErr(j.error || `Fire failed (${r.status})`);
121
+ return;
122
+ }
123
+ // executeRun is async — give it a moment to write the
124
+ // pipeline_runs row, then refetch state. Without this the UI
125
+ // would not see job.busy turn true, and Stop drain / Cancel
126
+ // all wouldn't appear until the next manual refresh.
127
+ setTimeout(() => { void refresh(); void loadRuns(id); }, 300);
128
+ } catch (e) { setErr(`Fire failed: ${e instanceof Error ? e.message : String(e)}`); }
129
+ }
130
+
131
+ async function cancelDrain(id: string, opts?: { cancelInflight?: boolean }) {
132
+ if (!confirm(opts?.cancelInflight
133
+ ? 'Cancel the drain AND stop any in-flight pipelines this Job dispatched?'
134
+ : 'Stop the sequential drain? In-flight pipelines keep running.')) return;
135
+ const qs = opts?.cancelInflight ? '?cancel_inflight=1' : '';
136
+ const r = await fetch(`/api/jobs/${id}/cancel${qs}`, { method: 'POST' });
137
+ if (!r.ok) { setErr(`Cancel failed: ${await r.text()}`); return; }
138
+ void refresh();
139
+ }
140
+
141
+ async function toggleConcurrencyMode(j: Job) {
142
+ const next = (j as any).concurrency_mode === 'parallel' ? 'sequential' : 'parallel';
143
+ const r = await fetch(`/api/jobs/${j.id}`, {
144
+ method: 'PATCH',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({ concurrency_mode: next }),
147
+ });
148
+ if (!r.ok) { setErr(`Failed to switch mode: ${await r.text()}`); return; }
149
+ void refresh();
150
+ }
151
+
152
+ async function toggleOnFailure(j: Job) {
153
+ const next = (j as any).on_failure === 'stop' ? 'continue' : 'stop';
154
+ const r = await fetch(`/api/jobs/${j.id}`, {
155
+ method: 'PATCH',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ on_failure: next }),
158
+ });
159
+ if (!r.ok) { setErr(`Failed to switch on_failure: ${await r.text()}`); return; }
160
+ void refresh();
107
161
  }
108
162
 
109
163
  async function toggle(j: Job) {
@@ -183,6 +237,9 @@ export default function JobsView({ onViewPipeline }: Props) {
183
237
  onToggle={() => toggle(j)}
184
238
  onDelete={() => remove(j.id)}
185
239
  onReset={() => resetDedup(j.id)}
240
+ onToggleMode={() => toggleConcurrencyMode(j)}
241
+ onToggleOnFailure={() => toggleOnFailure(j)}
242
+ onCancelDrain={(cancelInflight) => cancelDrain(j.id, { cancelInflight })}
186
243
  onViewPipeline={onViewPipeline}
187
244
  />
188
245
  ))}
@@ -192,11 +249,45 @@ export default function JobsView({ onViewPipeline }: Props) {
192
249
  );
193
250
  }
194
251
 
195
- function JobRow({ job, expanded, runs, onClick, onFire, onForceFire, onToggle, onDelete, onReset, onViewPipeline }: {
252
+ interface DispatchedPipeline {
253
+ dispatch_id: string;
254
+ job_run_id: string;
255
+ item_key: string;
256
+ item_preview: string | null;
257
+ pipeline_id: string;
258
+ pipeline_status: string;
259
+ workflow_name: string | null;
260
+ dispatched_at: string;
261
+ }
262
+
263
+ function JobRow({ job, expanded, runs, onClick, onFire, onForceFire, onToggle, onDelete, onReset, onToggleMode, onToggleOnFailure, onCancelDrain, onViewPipeline }: {
196
264
  job: Job; expanded: boolean; runs?: JobRun[];
197
265
  onClick: () => void; onFire: () => void; onForceFire: () => void; onToggle: () => void; onDelete: () => void; onReset: () => void;
266
+ onToggleMode: () => void;
267
+ onToggleOnFailure: () => void;
268
+ onCancelDrain: (cancelInflight: boolean) => void;
198
269
  onViewPipeline?: (id: string) => void;
199
270
  }) {
271
+ // Dispatched-pipelines mini list — loaded lazily on first expand. Tells
272
+ // the user "Job X spawned these pipelines, here's their state" without
273
+ // having to navigate to PipelineView per row.
274
+ const [pipes, setPipes] = useState<DispatchedPipeline[] | null>(null);
275
+ const [pipesLoading, setPipesLoading] = useState(false);
276
+ useEffect(() => {
277
+ if (!expanded) return;
278
+ let cancelled = false;
279
+ setPipesLoading(true);
280
+ fetch(`/api/jobs/${job.id}/dispatched-pipelines?limit=15`)
281
+ .then((r) => r.json())
282
+ .then((j) => { if (!cancelled) setPipes(j.pipelines || []); })
283
+ .catch(() => { if (!cancelled) setPipes([]); })
284
+ .finally(() => { if (!cancelled) setPipesLoading(false); });
285
+ return () => { cancelled = true; };
286
+ }, [expanded, job.id, job.last_run_at]);
287
+
288
+ const hasScheduledDrain = !!job.next_run_at;
289
+ const inflightCount = pipes ? pipes.filter((p) => p.pipeline_status === 'running' || p.pipeline_status === 'pending').length : 0;
290
+
200
291
  return (
201
292
  <div className="rounded border border-[var(--border)] bg-[var(--bg-secondary)]">
202
293
  <div onClick={onClick} className="px-3 py-2 cursor-pointer flex items-baseline gap-2">
@@ -207,6 +298,33 @@ function JobRow({ job, expanded, runs, onClick, onFire, onForceFire, onToggle, o
207
298
  <span className={`text-[9px] px-1.5 py-0.5 rounded ${job.enabled ? 'bg-[var(--green)]/15 text-[var(--green)]' : 'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]'}`}>
208
299
  {job.enabled ? 'enabled' : 'disabled'}
209
300
  </span>
301
+ {(job as any).busy && (
302
+ <span
303
+ className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]"
304
+ title={(job as any).busy_reason}
305
+ >running</span>
306
+ )}
307
+ {/* Show pacing mode — 'sequential' is the default (1 pipeline at a time);
308
+ 'parallel' fans out up to max_per_tick at once and is louder visually so
309
+ the user knows fan-out can happen. */}
310
+ <span
311
+ className={`text-[9px] px-1.5 py-0.5 rounded ${
312
+ (job as any).concurrency_mode === 'parallel'
313
+ ? 'bg-[var(--yellow)]/20 text-[var(--yellow)]'
314
+ : 'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]'
315
+ }`}
316
+ title={(job as any).concurrency_mode === 'parallel'
317
+ ? 'parallel — each tick dispatches up to max_per_tick pipelines concurrently'
318
+ : 'sequential — one pipeline at a time; next tick waits until prior finishes'}
319
+ >
320
+ {(job as any).concurrency_mode === 'parallel' ? 'parallel' : 'sequential'}
321
+ </span>
322
+ {(job as any).on_failure === 'stop' && (
323
+ <span
324
+ className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--red)]/20 text-[var(--red)]"
325
+ title="on_failure: stop — any item's pipeline failure halts the drain. Force-run to resume."
326
+ >stop on fail</span>
327
+ )}
210
328
  </div>
211
329
  <div className="text-[10px] text-[var(--text-secondary)] font-mono mt-0.5 truncate">
212
330
  {job.source_connector}.{job.source_tool} → {job.dispatch_type} · every {job.schedule_interval_minutes}m
@@ -219,14 +337,135 @@ function JobRow({ job, expanded, runs, onClick, onFire, onForceFire, onToggle, o
219
337
  {expanded && (
220
338
  <div className="border-t border-[var(--border)] px-3 py-2 bg-[var(--bg-tertiary)]">
221
339
  <div className="flex items-center gap-2 mb-2">
222
- <button onClick={(e) => { e.stopPropagation(); onFire(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">Run now</button>
223
- <button onClick={(e) => { e.stopPropagation(); onForceFire(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]" title="Reset dedup + run — re-dispatch every item this tick">Force run</button>
340
+ <button
341
+ onClick={(e) => { e.stopPropagation(); if (!(job as any).busy) onFire(); }}
342
+ disabled={!!(job as any).busy}
343
+ className={`text-[10px] px-2 py-0.5 border border-[var(--border)] rounded ${(job as any).busy ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--bg-secondary)]'}`}
344
+ title={(job as any).busy ? `Disabled: ${(job as any).busy_reason}` : 'Trigger one tick now'}
345
+ >Run now</button>
346
+ <button
347
+ onClick={(e) => { e.stopPropagation(); if (!(job as any).busy) onForceFire(); }}
348
+ disabled={!!(job as any).busy}
349
+ className={`text-[10px] px-2 py-0.5 border border-[var(--border)] rounded ${(job as any).busy ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--bg-secondary)]'}`}
350
+ title={(job as any).busy ? `Disabled: ${(job as any).busy_reason}` : 'Reset dedup + run — re-dispatch every item this tick'}
351
+ >Force run</button>
224
352
  <button onClick={(e) => { e.stopPropagation(); onToggle(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">{job.enabled ? 'Disable' : 'Enable'}</button>
225
353
  <button onClick={(e) => { e.stopPropagation(); onReset(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]" title="Wipe dedup state">Reset dedup</button>
354
+ <button
355
+ onClick={(e) => { e.stopPropagation(); onToggleMode(); }}
356
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
357
+ title={(job as any).concurrency_mode === 'parallel'
358
+ ? 'Switch to sequential — one pipeline at a time'
359
+ : 'Switch to parallel — fan out up to max_per_tick per tick'}
360
+ >
361
+ {(job as any).concurrency_mode === 'parallel' ? '→ Sequential' : '→ Parallel'}
362
+ </button>
363
+ <button
364
+ onClick={(e) => { e.stopPropagation(); onToggleOnFailure(); }}
365
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
366
+ title={(job as any).on_failure === 'stop'
367
+ ? 'on_failure=stop: halt drain on any failure. Click to switch to continue (default — skip failed item, proceed).'
368
+ : 'on_failure=continue (default): keep dispatching even if items fail. Click to switch to stop (halt on first failure).'}
369
+ >
370
+ {(job as any).on_failure === 'stop' ? '→ Continue on fail' : '→ Stop on fail'}
371
+ </button>
226
372
  <button onClick={(e) => { e.stopPropagation(); navigateLogs(`[jobs] ${job.id}`); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]" title="Tail forge.log filtered to this job's id">View logs</button>
373
+ {/* Cancel — visible only when something is in flight or a drain is queued. Hidden otherwise to keep the action bar lean. */}
374
+ {(hasScheduledDrain || inflightCount > 0) && (
375
+ <>
376
+ <button
377
+ onClick={(e) => { e.stopPropagation(); onCancelDrain(false); }}
378
+ className="text-[10px] px-2 py-0.5 border border-[var(--yellow)]/50 text-[var(--yellow)] rounded hover:bg-[var(--yellow)]/10"
379
+ title="Stop the sequential drain — clears next_run_at so no more items dispatch automatically. In-flight pipelines KEEP running."
380
+ >Stop drain</button>
381
+ {inflightCount > 0 && (
382
+ <button
383
+ onClick={(e) => { e.stopPropagation(); onCancelDrain(true); }}
384
+ className="text-[10px] px-2 py-0.5 border border-[var(--red)]/50 text-[var(--red)] rounded hover:bg-[var(--red)]/10"
385
+ title={`Stop drain AND cancel ${inflightCount} in-flight pipeline(s) this Job dispatched`}
386
+ >Cancel all ({inflightCount})</button>
387
+ )}
388
+ </>
389
+ )}
227
390
  <span className="flex-1" />
228
391
  <button onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-[10px] px-2 py-0.5 border border-[var(--red)]/50 text-[var(--red)] rounded hover:bg-[var(--red)]/10">Delete</button>
229
392
  </div>
393
+
394
+ {/* Dispatched pipelines panel — what this Job spawned, current state. */}
395
+ {(pipes && pipes.length > 0) && (() => {
396
+ // Aggregate counts by status for the summary header.
397
+ const c = { running: 0, pending: 0, done: 0, failed: 0, cancelled: 0, unknown: 0 } as Record<string, number>;
398
+ for (const p of pipes) { c[p.pipeline_status] = (c[p.pipeline_status] || 0) + 1; }
399
+ // Best-effort "items waiting to be dispatched" hint from the
400
+ // most recent run's deferred count. Sequential mode rolls
401
+ // these over to the next tick — they are NOT in
402
+ // dispatched-pipelines yet.
403
+ const latestDeferred = (() => {
404
+ if (!runs || runs.length === 0) return 0;
405
+ const m = String(runs[0].notes || '').match(/(\d+)\s*item\(s\)\s*deferred/i);
406
+ return m ? Number(m[1]) : 0;
407
+ })();
408
+ return (
409
+ <div className="mb-2">
410
+ <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1 flex items-baseline gap-2 flex-wrap">
411
+ <span>Dispatched pipelines</span>
412
+ <span className="text-[10px] font-normal normal-case text-[var(--text-primary)]">
413
+ {pipes.length} total
414
+ {c.running ? <> · <span className="text-[var(--accent)]">{c.running} running</span></> : null}
415
+ {c.pending ? <> · <span className="text-[var(--accent)]">{c.pending} pending</span></> : null}
416
+ {c.done ? <> · <span className="text-[var(--green)]">{c.done} done</span></> : null}
417
+ {c.failed ? <> · <span className="text-[var(--red)]">{c.failed} failed</span></> : null}
418
+ {c.cancelled ? <> · <span className="text-[var(--text-secondary)]">{c.cancelled} cancelled</span></>: null}
419
+ </span>
420
+ {latestDeferred > 0 && (
421
+ <span className="text-[10px] font-normal normal-case text-[var(--yellow)]">
422
+ · {latestDeferred} waiting in queue (deferred)
423
+ </span>
424
+ )}
425
+ {pipesLoading && <span className="text-[var(--text-secondary)]">· loading…</span>}
426
+ </div>
427
+ <div className="space-y-0.5">
428
+ {/* Trim to "what's actionable": all running/pending + the
429
+ one most-recent terminal entry. Full history would
430
+ bloat the panel and isn't useful here — go to the
431
+ Pipeline view for history. */}
432
+ {(() => {
433
+ const active = pipes.filter((p) => p.pipeline_status === 'running' || p.pipeline_status === 'pending');
434
+ const lastTerminal = pipes.find((p) =>
435
+ p.pipeline_status !== 'running' && p.pipeline_status !== 'pending');
436
+ const display = lastTerminal ? [...active, lastTerminal] : active;
437
+ return display;
438
+ })().map((p) => (
439
+ <div
440
+ key={p.dispatch_id}
441
+ className="flex items-baseline gap-2 text-[10px] font-mono py-0.5 px-1 rounded hover:bg-[var(--bg-secondary)]/50 cursor-pointer"
442
+ onClick={(e) => { e.stopPropagation(); if (onViewPipeline) onViewPipeline(p.pipeline_id); }}
443
+ title={`Click to open Pipeline view for ${p.pipeline_id}`}
444
+ >
445
+ <span className={
446
+ p.pipeline_status === 'running' ? 'text-[var(--accent)]'
447
+ : p.pipeline_status === 'pending' ? 'text-[var(--accent)]'
448
+ : p.pipeline_status === 'done' ? 'text-[var(--green)]'
449
+ : p.pipeline_status === 'failed' ? 'text-[var(--red)]'
450
+ : p.pipeline_status === 'cancelled' ? 'text-[var(--text-secondary)]'
451
+ : 'text-[var(--text-secondary)]'
452
+ }>
453
+ {p.pipeline_status === 'running' ? '🔄'
454
+ : p.pipeline_status === 'pending' ? '⏳'
455
+ : p.pipeline_status === 'done' ? '✅'
456
+ : p.pipeline_status === 'failed' ? '❌'
457
+ : p.pipeline_status === 'cancelled' ? '⏹'
458
+ : '·'}
459
+ </span>
460
+ <span className="text-[var(--text-secondary)] w-20 truncate">{p.pipeline_id.slice(0, 8)}</span>
461
+ <span className="truncate flex-1 text-[var(--text-primary)]">{p.item_key}</span>
462
+ <span className="text-[var(--text-secondary)]">{p.pipeline_status}</span>
463
+ </div>
464
+ ))}
465
+ </div>
466
+ </div>
467
+ );
468
+ })()}
230
469
  <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">Recent runs</div>
231
470
  {runs == null ? (
232
471
  <div className="text-[10px] text-[var(--text-secondary)]">Loading…</div>
@@ -310,6 +310,9 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
310
310
  const [workflowDesc, setWorkflowDesc] = useState('');
311
311
  const [varsProject, setVarsProject] = useState('');
312
312
  const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
313
+ // Preserve workflow-level for_each block round-trip (read on load, write on save).
314
+ // Editor is read-only for this field; edit via yaml export/import.
315
+ const [forEachSpec, setForEachSpec] = useState<{ source: any; as?: string; on_failure?: string; split?: string; before?: string[] } | null>(null);
313
316
  const nextNodeId = useRef(1);
314
317
 
315
318
  useEffect(() => {
@@ -329,6 +332,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
329
332
  if (parsed.name) setWorkflowName(parsed.name);
330
333
  if (parsed.description) setWorkflowDesc(parsed.description);
331
334
  if (parsed.vars?.project) setVarsProject(parsed.vars.project);
335
+ if (parsed.for_each && typeof parsed.for_each === 'object') setForEachSpec(parsed.for_each);
332
336
 
333
337
  const nodeEntries = Object.entries(parsed.nodes || {});
334
338
  const newNodes: Node<NodeData>[] = [];
@@ -465,6 +469,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
465
469
  name: workflowName,
466
470
  description: workflowDesc || undefined,
467
471
  vars: varsProject ? { project: varsProject } : undefined,
472
+ for_each: forEachSpec || undefined,
468
473
  nodes: {} as any,
469
474
  };
470
475
 
@@ -490,7 +495,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
490
495
 
491
496
  const YAML = require('yaml');
492
497
  return YAML.stringify(workflow);
493
- }, [nodes, edges, workflowName, workflowDesc, varsProject]);
498
+ }, [nodes, edges, workflowName, workflowDesc, varsProject, forEachSpec]);
494
499
 
495
500
  return (
496
501
  <div className="fixed inset-0 z-50 flex flex-col bg-[#0a0a1a]">
@@ -537,6 +542,38 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
537
542
  </button>
538
543
  </div>
539
544
 
545
+ {/* for_each banner — workflow wraps the DAG in a loop */}
546
+ {forEachSpec && (
547
+ <div className="border-b border-purple-500/30 bg-purple-500/10 px-4 py-2 flex items-center gap-3 text-xs shrink-0">
548
+ <span className="text-base">🔁</span>
549
+ <span className="font-semibold text-purple-300">for_each</span>
550
+ <span className="text-gray-400">— each</span>
551
+ <code className="text-purple-200 bg-purple-500/20 px-1.5 py-0.5 rounded">
552
+ {forEachSpec.as || 'item'}
553
+ </code>
554
+ <span className="text-gray-400">in</span>
555
+ <code className="text-purple-200 bg-purple-500/20 px-1.5 py-0.5 rounded font-mono">
556
+ {typeof forEachSpec.source === 'string'
557
+ ? forEachSpec.source
558
+ : Array.isArray(forEachSpec.source)
559
+ ? `[${forEachSpec.source.length} literal items]`
560
+ : '(complex source)'}
561
+ </code>
562
+ <span className="text-gray-500">
563
+ • on_failure: <span className="text-gray-300">{forEachSpec.on_failure || 'continue'}</span>
564
+ {forEachSpec.split && forEachSpec.split !== ',' && (
565
+ <> • split: <code className="text-gray-300">{JSON.stringify(forEachSpec.split)}</code></>
566
+ )}
567
+ {forEachSpec.before && forEachSpec.before.length > 0 && (
568
+ <> • setup: <code className="text-purple-200 bg-purple-500/20 px-1 rounded">[{forEachSpec.before.join(', ')}]</code></>
569
+ )}
570
+ </span>
571
+ <span className="ml-auto text-gray-500 italic">
572
+ Whole DAG below re-runs once per item • iteration history persists in pipeline.forEach.iterations
573
+ </span>
574
+ </div>
575
+ )}
576
+
540
577
  {/* Flow canvas */}
541
578
  <div className="flex-1">
542
579
  <ReactFlow