@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
@@ -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, desc]) => (
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="p-4 space-y-2 overflow-y-auto">
1279
- {selectedPipeline.nodeOrder.map((nodeId, idx) => {
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
+ }