@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
|
@@ -118,6 +118,28 @@ interface Pipeline {
|
|
|
118
118
|
nodeOrder: string[];
|
|
119
119
|
createdAt: string;
|
|
120
120
|
completedAt?: string;
|
|
121
|
+
// for_each loop state — present only when the workflow declared `for_each:`.
|
|
122
|
+
forEach?: {
|
|
123
|
+
items: unknown[];
|
|
124
|
+
currentIndex: number;
|
|
125
|
+
total: number;
|
|
126
|
+
asName: string;
|
|
127
|
+
onFailure: 'continue' | 'stop';
|
|
128
|
+
// Loop-setup node ids (run ONCE before iterations). Empty / absent for
|
|
129
|
+
// simple for_each (no `before:` block).
|
|
130
|
+
before?: string[];
|
|
131
|
+
// false while setup nodes are still running and items haven't been
|
|
132
|
+
// resolved yet; true once loop body can begin (or immediately when
|
|
133
|
+
// no before nodes).
|
|
134
|
+
itemsResolved: boolean;
|
|
135
|
+
iterations: Array<{
|
|
136
|
+
index: number;
|
|
137
|
+
status: 'done' | 'failed' | 'cancelled';
|
|
138
|
+
startedAt: string;
|
|
139
|
+
completedAt: string;
|
|
140
|
+
nodes: Record<string, { status: string; outputs: Record<string, string>; error?: string; taskId?: string }>;
|
|
141
|
+
}>;
|
|
142
|
+
};
|
|
121
143
|
conversation?: {
|
|
122
144
|
config: {
|
|
123
145
|
agents: { id: string; agent: string; role: string; project?: string }[];
|
|
@@ -147,6 +169,165 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
147
169
|
skipped: 'text-gray-500',
|
|
148
170
|
};
|
|
149
171
|
|
|
172
|
+
// ─── for_each state panel (setup + iterations) ───────────
|
|
173
|
+
//
|
|
174
|
+
// Renders above the DAG node list when pipeline.forEach is set. Two
|
|
175
|
+
// sections:
|
|
176
|
+
// 1. Setup — only when `forEach.before` is non-empty. Shows live status
|
|
177
|
+
// of the setup nodes (run once, stay done across iterations). Full
|
|
178
|
+
// DagNodeCards render in the main DAG below for live log access;
|
|
179
|
+
// this is just a compact status bar to make the loop structure clear.
|
|
180
|
+
// 2. Iterations — each completed iteration as a foldable card; current
|
|
181
|
+
// iter shown as a "running ↓" hint pointing to the DAG nodes below.
|
|
182
|
+
function ForEachStatePanel({ pipeline, onViewTask }: {
|
|
183
|
+
pipeline: Pipeline;
|
|
184
|
+
onViewTask?: (taskId: string) => void;
|
|
185
|
+
}) {
|
|
186
|
+
const fe = pipeline.forEach!;
|
|
187
|
+
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
|
188
|
+
const beforeIds = fe.before || [];
|
|
189
|
+
const setupPhase = !fe.itemsResolved;
|
|
190
|
+
|
|
191
|
+
const iterStatusIcon = (s: string) =>
|
|
192
|
+
s === 'done' ? '✅' : s === 'failed' ? '❌' : s === 'cancelled' ? '⏹' : '⏸';
|
|
193
|
+
const itemLabel = (item: unknown): string => {
|
|
194
|
+
if (item === null || item === undefined) return '∅';
|
|
195
|
+
if (typeof item === 'object') {
|
|
196
|
+
const o = item as any;
|
|
197
|
+
// Prefer common id-ish fields for compact display, fall back to JSON.
|
|
198
|
+
return String(o.iid ?? o.id ?? o.bug_id ?? JSON.stringify(item).slice(0, 40));
|
|
199
|
+
}
|
|
200
|
+
return String(item);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const done = fe.iterations.length;
|
|
204
|
+
const total = fe.total;
|
|
205
|
+
const okCount = fe.iterations.filter((i) => i.status === 'done').length;
|
|
206
|
+
const failCount = fe.iterations.filter((i) => i.status === 'failed').length;
|
|
207
|
+
const isFinished = pipeline.status === 'done' || pipeline.status === 'failed' || pipeline.status === 'cancelled';
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="border-b border-[var(--border)] pb-3">
|
|
211
|
+
{/* ── Setup section — only with for_each.before ── */}
|
|
212
|
+
{beforeIds.length > 0 && (
|
|
213
|
+
<div className="px-4 pt-2 pb-2 border-b border-[var(--border)]/50">
|
|
214
|
+
<div className="flex items-center gap-2 text-xs mb-1">
|
|
215
|
+
<span className="text-base">⚙️</span>
|
|
216
|
+
<span className="font-semibold text-[var(--text-primary)]">Setup</span>
|
|
217
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
218
|
+
(runs once, feeds for_each items)
|
|
219
|
+
</span>
|
|
220
|
+
<span className="ml-auto text-[10px] text-[var(--text-secondary)]">
|
|
221
|
+
{setupPhase ? 'resolving…' : `resolved → ${fe.total} items`}
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex flex-wrap gap-2 text-[11px]">
|
|
225
|
+
{beforeIds.map((nid) => {
|
|
226
|
+
const n = pipeline.nodes[nid];
|
|
227
|
+
if (!n) return null;
|
|
228
|
+
const clickable = !!(n.taskId && onViewTask);
|
|
229
|
+
const Tag = clickable ? 'button' : 'div';
|
|
230
|
+
return (
|
|
231
|
+
<Tag
|
|
232
|
+
key={nid}
|
|
233
|
+
onClick={clickable ? () => onViewTask!(n.taskId!) : undefined}
|
|
234
|
+
className={`flex items-center gap-1.5 px-2 py-0.5 border border-[var(--border)] rounded bg-[var(--bg-tertiary)]/30 text-left ${clickable ? 'hover:border-[var(--accent)] cursor-pointer' : ''}`}
|
|
235
|
+
title={clickable ? `Click to view task ${n.taskId}` : undefined}
|
|
236
|
+
>
|
|
237
|
+
<span className={STATUS_COLOR[n.status] ?? 'text-gray-400'}>
|
|
238
|
+
{STATUS_ICON[n.status] ?? '?'}
|
|
239
|
+
</span>
|
|
240
|
+
<span className="font-mono">{nid}</span>
|
|
241
|
+
{clickable && <span className="text-[9px] text-[var(--text-secondary)]">↗</span>}
|
|
242
|
+
{n.error && (
|
|
243
|
+
<span className="text-red-400 truncate max-w-[280px]" title={n.error}>
|
|
244
|
+
— {n.error.slice(0, 60)}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</Tag>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{/* ── Iterations header (hidden during setup phase: nothing to show yet) ── */}
|
|
255
|
+
{!setupPhase && (
|
|
256
|
+
<div className="px-4 py-2 flex items-center gap-3 text-xs">
|
|
257
|
+
<span className="font-semibold text-[var(--text-primary)]">
|
|
258
|
+
Loop {isFinished ? `${done}/${total}` : `${fe.currentIndex + 1}/${total}`}
|
|
259
|
+
</span>
|
|
260
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
261
|
+
(`{`{`}{`}`}{fe.asName}{`}`}{`}`} = each item)
|
|
262
|
+
</span>
|
|
263
|
+
<span className="ml-auto text-[10px]">
|
|
264
|
+
<span className="text-green-400 mr-2">✅ {okCount}</span>
|
|
265
|
+
{failCount > 0 && <span className="text-red-400 mr-2">❌ {failCount}</span>}
|
|
266
|
+
</span>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
<div className="px-4 space-y-1">
|
|
271
|
+
{fe.iterations.map((iter) => {
|
|
272
|
+
const isOpen = openIdx === iter.index;
|
|
273
|
+
return (
|
|
274
|
+
<div key={iter.index} className="border border-[var(--border)] rounded">
|
|
275
|
+
<button
|
|
276
|
+
onClick={() => setOpenIdx(isOpen ? null : iter.index)}
|
|
277
|
+
className="w-full px-2 py-1 flex items-center gap-2 hover:bg-[var(--bg-tertiary)] text-left"
|
|
278
|
+
>
|
|
279
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{isOpen ? '▼' : '▶'}</span>
|
|
280
|
+
<span>{iterStatusIcon(iter.status)}</span>
|
|
281
|
+
<span className="text-[10px] font-mono">
|
|
282
|
+
iter {iter.index}: {itemLabel(fe.items[iter.index])}
|
|
283
|
+
</span>
|
|
284
|
+
<span className="ml-auto text-[8px] text-[var(--text-secondary)]">
|
|
285
|
+
{new Date(iter.completedAt).toLocaleTimeString()}
|
|
286
|
+
</span>
|
|
287
|
+
</button>
|
|
288
|
+
{isOpen && (
|
|
289
|
+
<div className="px-3 py-2 space-y-1 border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
|
|
290
|
+
{Object.entries(iter.nodes).map(([nodeId, n]) => {
|
|
291
|
+
const clickable = !!(n.taskId && onViewTask);
|
|
292
|
+
const Tag = clickable ? 'button' : 'div';
|
|
293
|
+
return (
|
|
294
|
+
<Tag
|
|
295
|
+
key={nodeId}
|
|
296
|
+
onClick={clickable ? () => onViewTask!(n.taskId!) : undefined}
|
|
297
|
+
className={`flex items-start gap-2 text-[10px] w-full text-left rounded px-1 -mx-1 py-0.5 ${clickable ? 'hover:bg-[var(--bg-secondary)] cursor-pointer' : ''}`}
|
|
298
|
+
title={clickable ? `View task ${n.taskId}` : undefined}
|
|
299
|
+
>
|
|
300
|
+
<span className={STATUS_COLOR[n.status] ?? 'text-gray-400'}>
|
|
301
|
+
{STATUS_ICON[n.status] ?? '?'}
|
|
302
|
+
</span>
|
|
303
|
+
<span className="font-mono">{nodeId}</span>
|
|
304
|
+
{clickable && <span className="text-[9px] text-[var(--text-secondary)]">↗</span>}
|
|
305
|
+
{n.error && (
|
|
306
|
+
<span className="text-red-400 truncate flex-1" title={n.error}>
|
|
307
|
+
— {n.error.slice(0, 80)}
|
|
308
|
+
</span>
|
|
309
|
+
)}
|
|
310
|
+
</Tag>
|
|
311
|
+
);
|
|
312
|
+
})}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
})}
|
|
318
|
+
{!isFinished && fe.currentIndex < fe.total && (
|
|
319
|
+
<div className="border border-yellow-500/30 bg-yellow-500/5 rounded px-2 py-1 flex items-center gap-2">
|
|
320
|
+
<span>🔄</span>
|
|
321
|
+
<span className="text-[10px] font-mono text-yellow-400">
|
|
322
|
+
iter {fe.currentIndex}: {itemLabel(fe.items[fe.currentIndex])} — running ↓
|
|
323
|
+
</span>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
150
331
|
// ─── DAG Node Card with live logs ─────────────────────────
|
|
151
332
|
|
|
152
333
|
function DagNodeCard({ nodeId, node, nodeDef, onViewTask, onRetry }: {
|
|
@@ -471,6 +652,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
|
|
|
471
652
|
installed_version?: string; has_update?: boolean;
|
|
472
653
|
};
|
|
473
654
|
const [showMarketplace, setShowMarketplace] = useState(false);
|
|
655
|
+
const [showCleanup, setShowCleanup] = useState(false);
|
|
474
656
|
const [marketRows, setMarketRows] = useState<MarketRow[] | null>(null);
|
|
475
657
|
const [marketBusy, setMarketBusy] = useState(false);
|
|
476
658
|
const [marketErr, setMarketErr] = useState<string>('');
|
|
@@ -767,8 +949,26 @@ initial_prompt: "{{input.task}}"
|
|
|
767
949
|
className="text-[9px] text-blue-400 hover:underline"
|
|
768
950
|
title="Import a workflow from the marketplace as a new local copy"
|
|
769
951
|
>+ From marketplace</button>
|
|
952
|
+
<button
|
|
953
|
+
onClick={() => setShowCleanup(true)}
|
|
954
|
+
className="text-[9px] text-[var(--text-secondary)] hover:underline"
|
|
955
|
+
title="Bulk-delete old pipeline runs (terminal state only)"
|
|
956
|
+
>Cleanup</button>
|
|
770
957
|
</div>
|
|
771
958
|
|
|
959
|
+
{showCleanup && (
|
|
960
|
+
<CleanupModal
|
|
961
|
+
onClose={() => setShowCleanup(false)}
|
|
962
|
+
onCleaned={() => {
|
|
963
|
+
setShowCleanup(false);
|
|
964
|
+
// Force a full refetch so deleted rows disappear from the
|
|
965
|
+
// sidebar — easiest path is a page reload, no separate
|
|
966
|
+
// refresh fn lifted to this scope yet.
|
|
967
|
+
location.reload();
|
|
968
|
+
}}
|
|
969
|
+
/>
|
|
970
|
+
)}
|
|
971
|
+
|
|
772
972
|
{/* Marketplace import — each pipeline in the registry can be
|
|
773
973
|
cloned to as many local copies as you want, each with a
|
|
774
974
|
unique name. The local copy is fully independent of the
|
|
@@ -955,7 +1155,11 @@ initial_prompt: "{{input.task}}"
|
|
|
955
1155
|
{/* Input fields — project fields get a dropdown */}
|
|
956
1156
|
{currentWorkflow && Object.keys(currentWorkflow.input).length > 0 && (
|
|
957
1157
|
<div className="space-y-1.5">
|
|
958
|
-
{Object.entries(currentWorkflow.input).map(([key,
|
|
1158
|
+
{Object.entries(currentWorkflow.input).map(([key, spec]) => {
|
|
1159
|
+
// spec is `string | WorkflowInputFieldSpec`. Extract the description
|
|
1160
|
+
// text either way — never render an object directly.
|
|
1161
|
+
const desc = typeof spec === 'string' ? spec : (spec && (spec as any).description) || '';
|
|
1162
|
+
return (
|
|
959
1163
|
<div key={key}>
|
|
960
1164
|
<label className="text-[9px] text-[var(--text-secondary)]">{key}: {desc}</label>
|
|
961
1165
|
{key.toLowerCase() === 'project' ? (
|
|
@@ -975,7 +1179,8 @@ initial_prompt: "{{input.task}}"
|
|
|
975
1179
|
/>
|
|
976
1180
|
)}
|
|
977
1181
|
</div>
|
|
978
|
-
)
|
|
1182
|
+
);
|
|
1183
|
+
})}
|
|
979
1184
|
</div>
|
|
980
1185
|
)}
|
|
981
1186
|
|
|
@@ -1275,8 +1480,19 @@ initial_prompt: "{{input.task}}"
|
|
|
1275
1480
|
onViewTask={onViewTask}
|
|
1276
1481
|
/>
|
|
1277
1482
|
) : (
|
|
1278
|
-
<div className="
|
|
1279
|
-
{
|
|
1483
|
+
<div className="overflow-y-auto">
|
|
1484
|
+
{/* for_each loop: setup + iteration history above the current-iter nodes */}
|
|
1485
|
+
{selectedPipeline.forEach && (
|
|
1486
|
+
<ForEachStatePanel pipeline={selectedPipeline} onViewTask={onViewTask} />
|
|
1487
|
+
)}
|
|
1488
|
+
<div className="p-4 space-y-2">
|
|
1489
|
+
{(() => {
|
|
1490
|
+
// Hide for_each `before:` nodes from the main DAG — they
|
|
1491
|
+
// already render in the Setup section above. (Without
|
|
1492
|
+
// for_each they aren't a thing, so the filter is no-op.)
|
|
1493
|
+
const beforeSet = new Set(selectedPipeline.forEach?.before || []);
|
|
1494
|
+
return selectedPipeline.nodeOrder.filter((nid) => !beforeSet.has(nid));
|
|
1495
|
+
})().map((nodeId, idx) => {
|
|
1280
1496
|
const node = selectedPipeline.nodes[nodeId];
|
|
1281
1497
|
const wf = workflows.find(w => w.name === selectedPipeline.workflowName);
|
|
1282
1498
|
const nodeDef = wf?.nodes?.[nodeId];
|
|
@@ -1297,6 +1513,7 @@ initial_prompt: "{{input.task}}"
|
|
|
1297
1513
|
</div>
|
|
1298
1514
|
);
|
|
1299
1515
|
})}
|
|
1516
|
+
</div>
|
|
1300
1517
|
</div>
|
|
1301
1518
|
)}
|
|
1302
1519
|
</>
|
|
@@ -1483,3 +1700,107 @@ function MissingToolsBanner() {
|
|
|
1483
1700
|
</div>
|
|
1484
1701
|
);
|
|
1485
1702
|
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Bulk delete old terminal-state pipeline runs AND task runs.
|
|
1706
|
+
* Both share the same age cutoff. Running/pending rows are always
|
|
1707
|
+
* skipped by the backend.
|
|
1708
|
+
*/
|
|
1709
|
+
function CleanupModal({ onClose, onCleaned }: { onClose: () => void; onCleaned: () => void }) {
|
|
1710
|
+
const [days, setDays] = useState(7);
|
|
1711
|
+
const [scope, setScope] = useState<'both' | 'pipelines' | 'tasks'>('both');
|
|
1712
|
+
const [busy, setBusy] = useState(false);
|
|
1713
|
+
const [result, setResult] = useState<{ tasks?: number; pipelines?: number; before?: string } | null>(null);
|
|
1714
|
+
const [err, setErr] = useState('');
|
|
1715
|
+
|
|
1716
|
+
async function run() {
|
|
1717
|
+
if (busy) return;
|
|
1718
|
+
if (!confirm(`Delete all ${scope === 'both' ? 'tasks + pipeline runs' : scope} older than ${days} day(s)? This cannot be undone.`)) return;
|
|
1719
|
+
setBusy(true); setErr('');
|
|
1720
|
+
try {
|
|
1721
|
+
const body = JSON.stringify({ older_than_days: days });
|
|
1722
|
+
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body };
|
|
1723
|
+
let tasks: number | undefined;
|
|
1724
|
+
let pipelines: number | undefined;
|
|
1725
|
+
let before: string | undefined;
|
|
1726
|
+
if (scope === 'both' || scope === 'tasks') {
|
|
1727
|
+
const r = await fetch('/api/tasks/bulk-delete', opts);
|
|
1728
|
+
const j = await r.json();
|
|
1729
|
+
if (!r.ok) throw new Error(j.error || `tasks bulk-delete HTTP ${r.status}`);
|
|
1730
|
+
tasks = j.removed; before = j.before;
|
|
1731
|
+
}
|
|
1732
|
+
if (scope === 'both' || scope === 'pipelines') {
|
|
1733
|
+
const r = await fetch('/api/pipelines/bulk-delete', opts);
|
|
1734
|
+
const j = await r.json();
|
|
1735
|
+
if (!r.ok) throw new Error(j.error || `pipelines bulk-delete HTTP ${r.status}`);
|
|
1736
|
+
pipelines = j.removed; before = before || j.before;
|
|
1737
|
+
}
|
|
1738
|
+
setResult({ tasks, pipelines, before });
|
|
1739
|
+
} catch (e) {
|
|
1740
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
1741
|
+
} finally { setBusy(false); }
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
return (
|
|
1745
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
1746
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded p-4 w-[420px] max-w-[90vw]">
|
|
1747
|
+
<div className="flex items-baseline justify-between mb-3">
|
|
1748
|
+
<h2 className="text-[13px] font-semibold">Cleanup old runs</h2>
|
|
1749
|
+
<button onClick={onClose} className="text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
1750
|
+
</div>
|
|
1751
|
+
<div className="space-y-2 mb-3">
|
|
1752
|
+
<label className="block text-[11px]">
|
|
1753
|
+
<span className="text-[var(--text-secondary)]">Older than (days)</span>
|
|
1754
|
+
<input
|
|
1755
|
+
type="number"
|
|
1756
|
+
min={0}
|
|
1757
|
+
value={days}
|
|
1758
|
+
onChange={(e) => setDays(Math.max(0, Number(e.target.value) || 0))}
|
|
1759
|
+
className="block w-full mt-0.5 px-2 py-1 text-[11px] border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1760
|
+
/>
|
|
1761
|
+
</label>
|
|
1762
|
+
<label className="block text-[11px]">
|
|
1763
|
+
<span className="text-[var(--text-secondary)]">Scope</span>
|
|
1764
|
+
<select
|
|
1765
|
+
value={scope}
|
|
1766
|
+
onChange={(e) => setScope(e.target.value as any)}
|
|
1767
|
+
className="block w-full mt-0.5 px-2 py-1 text-[11px] border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
1768
|
+
>
|
|
1769
|
+
<option value="both">Both — tasks + pipeline runs</option>
|
|
1770
|
+
<option value="pipelines">Pipeline runs only</option>
|
|
1771
|
+
<option value="tasks">Tasks only</option>
|
|
1772
|
+
</select>
|
|
1773
|
+
</label>
|
|
1774
|
+
<p className="text-[10px] text-[var(--text-secondary)] leading-relaxed">
|
|
1775
|
+
Only terminal-state rows are removed (done / failed / cancelled).
|
|
1776
|
+
Running and pending entries are never touched.
|
|
1777
|
+
</p>
|
|
1778
|
+
</div>
|
|
1779
|
+
{err && (
|
|
1780
|
+
<div className="text-[10px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mb-2">{err}</div>
|
|
1781
|
+
)}
|
|
1782
|
+
{result && (
|
|
1783
|
+
<div className="text-[10px] text-[var(--green)] bg-[var(--green)]/10 rounded p-2 mb-2">
|
|
1784
|
+
Removed
|
|
1785
|
+
{result.tasks != null ? ` · ${result.tasks} task(s)` : null}
|
|
1786
|
+
{result.pipelines != null ? ` · ${result.pipelines} pipeline run(s)` : null}
|
|
1787
|
+
{result.before ? `. Cutoff: ${result.before}` : null}
|
|
1788
|
+
</div>
|
|
1789
|
+
)}
|
|
1790
|
+
<div className="flex justify-end gap-2">
|
|
1791
|
+
<button onClick={onClose} className="text-[11px] px-3 py-1 border border-[var(--border)] rounded">Close</button>
|
|
1792
|
+
<button
|
|
1793
|
+
onClick={() => void run()}
|
|
1794
|
+
disabled={busy}
|
|
1795
|
+
className="text-[11px] px-3 py-1 border border-[var(--red)] text-[var(--red)] rounded hover:bg-[var(--red)]/10 disabled:opacity-50"
|
|
1796
|
+
>
|
|
1797
|
+
{busy ? 'Deleting…' : 'Delete'}
|
|
1798
|
+
</button>
|
|
1799
|
+
{result && (
|
|
1800
|
+
<button onClick={onCleaned} className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded">Done</button>
|
|
1801
|
+
)}
|
|
1802
|
+
</div>
|
|
1803
|
+
</div>
|
|
1804
|
+
</div>
|
|
1805
|
+
);
|
|
1806
|
+
}
|