@aion0/forge 0.9.1 → 0.9.3
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/RELEASE_NOTES.md +5 -5
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +106 -0
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/telegram-bot.ts +9 -3
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- 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>
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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 —
|
|
369
|
-
|
|
370
|
-
|
|
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', '
|
|
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', '
|
|
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', '
|
|
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
|
-
{(['
|
|
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',
|
|
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>}>
|
package/components/JobsView.tsx
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
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
|