@growthub/cli 0.13.4 → 0.13.6

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 (52) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/QUICKSTART.md +19 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/.env.example +8 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/action/execute/route.js +60 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/actions/route.js +50 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/connect-session/route.js +68 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/connection-status/route.js +56 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/proxy/route.js +67 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integrations/nango/status/route.js +50 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +184 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +25 -2
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +326 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +161 -50
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/NangoConnectionPanel.jsx +496 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +6 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +88 -1
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +41 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/WorkspaceGraphInspectorPanel.jsx +226 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +120 -17
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/nango/page.jsx +167 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +1 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +67 -11
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +31 -10
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +16 -14
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/env.js +7 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/index.js +38 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/nango-adapter.js +552 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/nango-config-loader.js +202 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/nango/nango-schema.js +303 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +49 -10
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +1 -1
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/nango.js +49 -0
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/template-registry.js +4 -2
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +923 -0
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +14 -1
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +218 -7
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +28 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-inputs.js +43 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +3 -1
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +36 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-chart-values.js +53 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +2 -1
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-graph.js +646 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-selectors.js +249 -0
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +1186 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +102 -3
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/bundles/growthub-custom-workspace-starter-v1.json +1 -0
  49. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  50. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/project-management.config.json +276 -0
  51. package/dist/index.js +127 -44
  52. package/package.json +1 -1
@@ -0,0 +1,496 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { CheckCircle, ExternalLink, Loader2, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
5
+
6
+ /**
7
+ * NangoConnectionPanel — interactive sidecar for an api-registry row whose
8
+ * `connectorKind === "nango"`. Aligns with Nango's documented OAuth
9
+ * lifecycle:
10
+ *
11
+ * 1. Create Connect Session — needs `providerConfigKey` only. Nango
12
+ * mints a session token + connect_link. The user opens the link and
13
+ * completes OAuth on the provider.
14
+ * 2. Nango generates the `connectionId` server-side and delivers it via
15
+ * the auth webhook (which an operator can persist into the row's
16
+ * `connectionIds` later — webhook persistence is a follow-up).
17
+ * 3. The user pastes the `connectionId` shown in Nango Cloud / received
18
+ * via webhook into the panel; "Check Connection" then verifies it.
19
+ * 4. Reconnect uses the existing `connectionId` as input and produces a
20
+ * fresh authorization session for the same connection.
21
+ *
22
+ * The panel reads `providerConfigKey`, `integrationId`, and `connectionIds`
23
+ * off the row (with sensible fallbacks) and never persists secrets. It
24
+ * calls server routes which themselves go through `@nangohq/node`. The
25
+ * browser never sees the Nango secret key or any provider OAuth credential.
26
+ *
27
+ * Props:
28
+ * row — the api-registry row being edited
29
+ * disabled — disable all controls (typically when the parent is saving)
30
+ * onUpdateRow(patch) — optional: parent applies patch (e.g. status:
31
+ * "connected", lastTested timestamp) when verification succeeds
32
+ */
33
+
34
+ const POLL_INTERVAL_MS = 3000;
35
+ const POLL_DURATION_MS = 60000;
36
+
37
+ function deriveProviderConfigKey(row) {
38
+ if (typeof row?.providerConfigKey === "string" && row.providerConfigKey.trim()) {
39
+ return row.providerConfigKey.trim();
40
+ }
41
+ if (typeof row?.integrationId === "string" && row.integrationId.trim()) {
42
+ return row.integrationId.trim();
43
+ }
44
+ return "";
45
+ }
46
+
47
+ function deriveDefaultConnectionId(row) {
48
+ if (Array.isArray(row?.connectionIds) && row.connectionIds.length) {
49
+ return String(row.connectionIds[0]).trim();
50
+ }
51
+ if (typeof row?.connectionIds === "string" && row.connectionIds.trim()) {
52
+ return row.connectionIds.split(",").map((c) => c.trim()).filter(Boolean)[0] || "";
53
+ }
54
+ if (typeof row?.connectionId === "string" && row.connectionId.trim()) {
55
+ return row.connectionId.trim();
56
+ }
57
+ return "";
58
+ }
59
+
60
+ function deriveInitialStatus(row) {
61
+ const status = typeof row?.status === "string" ? row.status.trim().toLowerCase() : "";
62
+ if (status === "connected") {
63
+ return {
64
+ kind: "connected",
65
+ message: row?.lastTested
66
+ ? `Connected · last verified ${formatRelativeTime(row.lastTested)}`
67
+ : "Connected"
68
+ };
69
+ }
70
+ if (status === "failed" || status === "error") {
71
+ return { kind: "error", message: "Last verification failed. Re-check the connection." };
72
+ }
73
+ return { kind: "unknown", message: "" };
74
+ }
75
+
76
+ function formatRelativeTime(isoString) {
77
+ const ts = new Date(isoString);
78
+ if (Number.isNaN(ts.getTime())) return "recently";
79
+ const diffMs = Date.now() - ts.getTime();
80
+ if (diffMs < 60_000) return "just now";
81
+ if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
82
+ if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
83
+ return `${Math.floor(diffMs / 86_400_000)}d ago`;
84
+ }
85
+
86
+ function validateProviderConfigKey(value) {
87
+ if (!value) return "providerConfigKey is required (the Nango integration key).";
88
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(value)) {
89
+ return "Use letters, digits, _, ., or - (max 64 chars, starts alphanumeric).";
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function validateConnectionIdField(value, { required }) {
95
+ if (!value) return required ? "connectionId is required to verify or reconnect." : null;
96
+ if (value.length > 256) return "connectionId is too long (max 256 chars).";
97
+ return null;
98
+ }
99
+
100
+ function StatusBadge({ status, label }) {
101
+ const map = {
102
+ connected: { className: "dm-api-action-card-status connected", Icon: CheckCircle },
103
+ "not-connected": { className: "dm-api-action-card-status warn", Icon: XCircle },
104
+ error: { className: "dm-api-action-card-status error", Icon: XCircle },
105
+ pending: { className: "dm-api-action-card-status pending", Icon: Loader2 },
106
+ unknown: { className: "dm-api-action-card-status", Icon: ShieldCheck }
107
+ };
108
+ const { className, Icon } = map[status] || map.unknown;
109
+ return (
110
+ <span className={className} role="status" aria-live="polite">
111
+ <Icon size={14} aria-hidden="true" />
112
+ <span>{label || status}</span>
113
+ </span>
114
+ );
115
+ }
116
+
117
+ export function NangoConnectionPanel({ row, disabled, onUpdateRow }) {
118
+ const initialProviderConfigKey = useMemo(() => deriveProviderConfigKey(row), [row]);
119
+ const initialConnectionId = useMemo(() => deriveDefaultConnectionId(row), [row]);
120
+ const initialStatus = useMemo(() => deriveInitialStatus(row), [row]);
121
+ const lastTested = typeof row?.lastTested === "string" ? row.lastTested : "";
122
+ const persistedStatus = typeof row?.status === "string" ? row.status.trim().toLowerCase() : "";
123
+
124
+ const [providerConfigKey, setProviderConfigKey] = useState(initialProviderConfigKey);
125
+ const [connectionId, setConnectionId] = useState(initialConnectionId);
126
+ const [fieldErrors, setFieldErrors] = useState({});
127
+ const [creatingSession, setCreatingSession] = useState(false);
128
+ const [checkingConnection, setCheckingConnection] = useState(false);
129
+ const [polling, setPolling] = useState(false);
130
+ const [statusKind, setStatusKind] = useState(initialStatus.kind);
131
+ const [statusMessage, setStatusMessage] = useState(initialStatus.message);
132
+ const [connectLink, setConnectLink] = useState(null);
133
+ const [sessionMode, setSessionMode] = useState(null);
134
+ const [lastSummary, setLastSummary] = useState(null);
135
+ const [errorRecovery, setErrorRecovery] = useState(null);
136
+
137
+ const pollTimerRef = useRef(null);
138
+ const pollDeadlineRef = useRef(0);
139
+
140
+ useEffect(() => {
141
+ setProviderConfigKey(initialProviderConfigKey);
142
+ setConnectionId(initialConnectionId);
143
+ setFieldErrors({});
144
+ setConnectLink(null);
145
+ setSessionMode(null);
146
+ setLastSummary(null);
147
+ setErrorRecovery(null);
148
+ setStatusKind(initialStatus.kind);
149
+ setStatusMessage(initialStatus.message);
150
+ setPolling(false);
151
+ if (pollTimerRef.current) {
152
+ clearTimeout(pollTimerRef.current);
153
+ pollTimerRef.current = null;
154
+ }
155
+ }, [row?.id, row?.integrationId, initialProviderConfigKey, initialConnectionId, initialStatus.kind, initialStatus.message]);
156
+
157
+ useEffect(() => () => {
158
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
159
+ }, []);
160
+
161
+ const handleProviderConfigKeyBlur = useCallback((value) => {
162
+ setFieldErrors((prev) => ({ ...prev, providerConfigKey: validateProviderConfigKey(value.trim()) }));
163
+ }, []);
164
+
165
+ const handleConnectionIdBlur = useCallback((value) => {
166
+ setFieldErrors((prev) => ({ ...prev, connectionId: validateConnectionIdField(value.trim(), { required: false }) }));
167
+ }, []);
168
+
169
+ const runStatusCheck = useCallback(async (silent = false) => {
170
+ const connectionIdValue = connectionId.trim();
171
+ const connectionIdError = validateConnectionIdField(connectionIdValue, { required: true });
172
+ if (connectionIdError) {
173
+ setFieldErrors((prev) => ({ ...prev, connectionId: connectionIdError }));
174
+ return { ok: false, missingConnectionId: true };
175
+ }
176
+ if (!silent) setCheckingConnection(true);
177
+ setErrorRecovery(null);
178
+ try {
179
+ const response = await fetch("/api/workspace/integrations/nango/connection-status", {
180
+ method: "POST",
181
+ headers: { "content-type": "application/json" },
182
+ body: JSON.stringify({
183
+ providerConfigKey: providerConfigKey.trim(),
184
+ connectionId: connectionIdValue
185
+ })
186
+ });
187
+ const payload = await response.json();
188
+ if (!response.ok || !payload?.ok) {
189
+ const message = payload?.error || `HTTP ${response.status}`;
190
+ setStatusKind("error");
191
+ setStatusMessage(message);
192
+ setErrorRecovery({
193
+ message,
194
+ code: payload?.code || null,
195
+ hint: payload?.code === "NANGO_NOT_CONFIGURED"
196
+ ? "Set NANGO_SECRET_KEY in this runtime to enable Nango."
197
+ : payload?.code === "NANGO_SDK_UNAVAILABLE"
198
+ ? "Install @nangohq/node in apps/workspace."
199
+ : null
200
+ });
201
+ return { ok: false };
202
+ }
203
+ setLastSummary(payload);
204
+ if (payload.status === "connected") {
205
+ setStatusKind("connected");
206
+ setStatusMessage(`Connected · environment: ${payload.environment || "dev"}`);
207
+ if (typeof onUpdateRow === "function") {
208
+ onUpdateRow({
209
+ status: "connected",
210
+ lastTested: new Date().toISOString(),
211
+ lastResponse: JSON.stringify({ connection: payload.connection || {} })
212
+ });
213
+ }
214
+ return { ok: true, connected: true };
215
+ }
216
+ setStatusKind("not-connected");
217
+ setStatusMessage(payload.reason || "Not connected yet.");
218
+ return { ok: true, connected: false };
219
+ } catch (error) {
220
+ const message = error?.message || "network error";
221
+ setStatusKind("error");
222
+ setStatusMessage(message);
223
+ setErrorRecovery({ message, code: null, hint: null });
224
+ return { ok: false };
225
+ } finally {
226
+ if (!silent) setCheckingConnection(false);
227
+ }
228
+ }, [providerConfigKey, connectionId, onUpdateRow]);
229
+
230
+ const startPolling = useCallback(() => {
231
+ if (!connectionId.trim()) {
232
+ // Without a connectionId we can't poll — the user must paste it once
233
+ // Nango delivers it (via Connect UI or the auth webhook).
234
+ return;
235
+ }
236
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
237
+ pollDeadlineRef.current = Date.now() + POLL_DURATION_MS;
238
+ setPolling(true);
239
+ const tick = async () => {
240
+ if (Date.now() > pollDeadlineRef.current) {
241
+ setPolling(false);
242
+ if (statusKind !== "connected") {
243
+ setStatusKind("not-connected");
244
+ setStatusMessage("Still waiting on Nango Connect — click Check Connection when you've finished OAuth.");
245
+ }
246
+ return;
247
+ }
248
+ const result = await runStatusCheck(true);
249
+ if (result?.connected) {
250
+ setPolling(false);
251
+ return;
252
+ }
253
+ pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS);
254
+ };
255
+ pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS);
256
+ }, [runStatusCheck, statusKind, connectionId]);
257
+
258
+ const createSession = useCallback(async ({ reconnect }) => {
259
+ const providerError = validateProviderConfigKey(providerConfigKey.trim());
260
+ const connectionIdError = reconnect
261
+ ? validateConnectionIdField(connectionId.trim(), { required: true })
262
+ : null;
263
+ setFieldErrors((prev) => ({ ...prev, providerConfigKey: providerError, connectionId: connectionIdError }));
264
+ if (providerError || connectionIdError) return;
265
+
266
+ setCreatingSession(true);
267
+ setConnectLink(null);
268
+ setSessionMode(null);
269
+ setErrorRecovery(null);
270
+ setStatusKind("pending");
271
+ setStatusMessage(reconnect ? "Creating Nango Reconnect session…" : "Creating Nango Connect session…");
272
+
273
+ // Build correlation tags so the auth webhook can identify which row
274
+ // asked for OAuth. No secrets — only stable identifiers.
275
+ const tags = {};
276
+ if (typeof row?.id === "string" && row.id.trim()) tags.row_id = row.id.trim();
277
+ if (typeof row?.integrationId === "string" && row.integrationId.trim()) tags.integration_id = row.integrationId.trim();
278
+ if (typeof row?.objectId === "string" && row.objectId.trim()) tags.object_id = row.objectId.trim();
279
+
280
+ try {
281
+ const response = await fetch("/api/workspace/integrations/nango/connect-session", {
282
+ method: "POST",
283
+ headers: { "content-type": "application/json" },
284
+ body: JSON.stringify({
285
+ providerConfigKey: providerConfigKey.trim(),
286
+ ...(reconnect ? { connectionId: connectionId.trim(), reconnect: true } : {}),
287
+ tags
288
+ })
289
+ });
290
+ const payload = await response.json();
291
+ if (!response.ok || !payload?.ok) {
292
+ const message = payload?.error || `HTTP ${response.status}`;
293
+ setStatusKind("error");
294
+ setStatusMessage(message);
295
+ setErrorRecovery({
296
+ message,
297
+ code: payload?.code || null,
298
+ hint: payload?.code === "NANGO_NOT_CONFIGURED"
299
+ ? "Set NANGO_SECRET_KEY in this runtime to enable Nango."
300
+ : payload?.code === "NANGO_SDK_UNAVAILABLE"
301
+ ? "Install @nangohq/node in apps/workspace."
302
+ : null
303
+ });
304
+ return;
305
+ }
306
+ if (!payload.connectLink) {
307
+ setStatusKind("error");
308
+ setStatusMessage("Nango returned a session without a connect_link. Check your Nango Cloud setup.");
309
+ return;
310
+ }
311
+ setConnectLink(payload.connectLink);
312
+ setSessionMode(payload.mode || (reconnect ? "reconnect" : "connect"));
313
+ setStatusKind("pending");
314
+ setStatusMessage(
315
+ reconnect
316
+ ? "Reconnect link ready — complete OAuth, then we'll re-verify automatically."
317
+ : "Open the Connect link to finish OAuth. Nango will return a connectionId — paste it below or wait for the auth webhook to persist it."
318
+ );
319
+ try {
320
+ const win = window.open(payload.connectLink, "_blank", "noopener,noreferrer");
321
+ if (win) win.focus();
322
+ } catch {
323
+ // window.open may be blocked silently — the visible link below still works.
324
+ }
325
+ // Auto-poll only when we already have a connectionId (reconnect flow,
326
+ // or the user pre-filled it for a known existing connection).
327
+ if (connectionId.trim()) {
328
+ startPolling();
329
+ }
330
+ } catch (error) {
331
+ const message = error?.message || "network error";
332
+ setStatusKind("error");
333
+ setStatusMessage(message);
334
+ setErrorRecovery({ message, code: null, hint: null });
335
+ } finally {
336
+ setCreatingSession(false);
337
+ }
338
+ }, [providerConfigKey, connectionId, row, startPolling]);
339
+
340
+ const handleCreateSession = useCallback(() => createSession({ reconnect: false }), [createSession]);
341
+ const handleReconnect = useCallback(() => createSession({ reconnect: true }), [createSession]);
342
+
343
+ const handleDisconnect = useCallback(() => {
344
+ setConnectLink(null);
345
+ setSessionMode(null);
346
+ setLastSummary(null);
347
+ setStatusKind("unknown");
348
+ setStatusMessage("");
349
+ setPolling(false);
350
+ if (pollTimerRef.current) {
351
+ clearTimeout(pollTimerRef.current);
352
+ pollTimerRef.current = null;
353
+ }
354
+ if (typeof onUpdateRow === "function") {
355
+ onUpdateRow({ status: "configured", lastResponse: "" });
356
+ }
357
+ }, [onUpdateRow]);
358
+
359
+ const isBusy = creatingSession || checkingConnection || polling;
360
+ const hasProviderError = Boolean(fieldErrors.providerConfigKey);
361
+ const hasConnectionIdError = Boolean(fieldErrors.connectionId);
362
+ const hasConnectionId = Boolean(connectionId.trim());
363
+
364
+ return (
365
+ <section className="dm-api-action-card" aria-label="Nango connection">
366
+ <div className="dm-api-action-card-body">
367
+ <p className="dm-api-action-card-eyebrow">Nango</p>
368
+ <h3>Connect this API through Nango</h3>
369
+ <p>
370
+ Nango handles OAuth and API authentication for hundreds of providers. Your provider credentials stay on Nango — this workspace only sees safe connection metadata, never tokens.
371
+ </p>
372
+
373
+ <ol className="dm-nango-steps">
374
+ <li>Enter the <strong>providerConfigKey</strong> (the Nango integration key).</li>
375
+ <li>Click <strong>Create Connect Session</strong> and complete OAuth in the new tab.</li>
376
+ <li>Paste the <strong>connectionId</strong> Nango shows you (or wait for the auth webhook to persist it), then click <strong>Check Connection</strong>.</li>
377
+ </ol>
378
+
379
+ <div className="dm-nango-fields">
380
+ <label className="dm-field">
381
+ <span>providerConfigKey</span>
382
+ <input
383
+ type="text"
384
+ value={providerConfigKey}
385
+ onChange={(event) => setProviderConfigKey(event.target.value)}
386
+ onBlur={(event) => handleProviderConfigKeyBlur(event.target.value)}
387
+ placeholder="e.g. hubspot-prod"
388
+ disabled={disabled || isBusy}
389
+ aria-invalid={hasProviderError}
390
+ aria-describedby={hasProviderError ? "nango-pck-error" : undefined}
391
+ />
392
+ {hasProviderError
393
+ ? <small id="nango-pck-error" className="dm-field-error">{fieldErrors.providerConfigKey}</small>
394
+ : <small className="dm-field-hint">Defaults to <code>integrationId</code> when blank.</small>}
395
+ </label>
396
+
397
+ <label className="dm-field">
398
+ <span>connectionId <small>(required to verify or reconnect)</small></span>
399
+ <input
400
+ type="text"
401
+ value={connectionId}
402
+ onChange={(event) => setConnectionId(event.target.value)}
403
+ onBlur={(event) => handleConnectionIdBlur(event.target.value)}
404
+ placeholder="Nango returns this after OAuth"
405
+ disabled={disabled || isBusy}
406
+ aria-invalid={hasConnectionIdError}
407
+ aria-describedby={hasConnectionIdError ? "nango-cid-error" : undefined}
408
+ />
409
+ {hasConnectionIdError
410
+ ? <small id="nango-cid-error" className="dm-field-error">{fieldErrors.connectionId}</small>
411
+ : <small className="dm-field-hint">Nango generates this during OAuth and delivers it via auth webhook.</small>}
412
+ </label>
413
+ </div>
414
+
415
+ <StatusBadge status={statusKind} label={statusMessage || statusKind} />
416
+
417
+ {persistedStatus === "connected" && lastTested ? (
418
+ <p className="dm-nango-last-tested" aria-label="Last connection verification">
419
+ Last verified <time dateTime={lastTested}>{formatRelativeTime(lastTested)}</time>
420
+ </p>
421
+ ) : null}
422
+
423
+ {connectLink ? (
424
+ <p className="dm-nango-connect-link">
425
+ <a href={connectLink} target="_blank" rel="noopener noreferrer">
426
+ <ExternalLink size={14} aria-hidden="true" />
427
+ {sessionMode === "reconnect" ? " Reopen Nango Reconnect link" : " Reopen Nango Connect link"}
428
+ </a>
429
+ </p>
430
+ ) : null}
431
+
432
+ {errorRecovery ? (
433
+ <div className="dm-nango-error-recovery" role="alert">
434
+ <p>{errorRecovery.message}</p>
435
+ {errorRecovery.hint ? <p className="dm-nango-error-hint">{errorRecovery.hint}</p> : null}
436
+ <button
437
+ type="button"
438
+ className="dm-btn-outline"
439
+ onClick={() => runStatusCheck(false)}
440
+ disabled={disabled || isBusy || hasProviderError}
441
+ >
442
+ <RefreshCw size={14} aria-hidden="true" /> Try Again
443
+ </button>
444
+ </div>
445
+ ) : null}
446
+ </div>
447
+
448
+ <div className="dm-api-action-card-actions">
449
+ <button
450
+ type="button"
451
+ className="dm-btn-primary-sm dm-api-action-card-cta"
452
+ onClick={handleCreateSession}
453
+ disabled={disabled || isBusy || hasProviderError}
454
+ >
455
+ {creatingSession && sessionMode !== "reconnect"
456
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Creating session…</>
457
+ : "Create Connect Session"}
458
+ </button>
459
+ {hasConnectionId ? (
460
+ <button
461
+ type="button"
462
+ className="dm-btn-outline dm-api-action-card-cta"
463
+ onClick={handleReconnect}
464
+ disabled={disabled || isBusy || hasProviderError || hasConnectionIdError}
465
+ >
466
+ {creatingSession && sessionMode === "reconnect"
467
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Reconnecting…</>
468
+ : "Reconnect"}
469
+ </button>
470
+ ) : null}
471
+ <button
472
+ type="button"
473
+ className="dm-btn-outline dm-api-action-card-cta"
474
+ onClick={() => runStatusCheck(false)}
475
+ disabled={disabled || isBusy || hasProviderError}
476
+ >
477
+ {checkingConnection
478
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Checking…</>
479
+ : polling
480
+ ? <><Loader2 className="dm-spinner" size={14} aria-hidden="true" /> Auto-polling…</>
481
+ : "Check Connection"}
482
+ </button>
483
+ {(statusKind === "connected" || persistedStatus === "connected") ? (
484
+ <button
485
+ type="button"
486
+ className="dm-btn-outline dm-api-action-card-cta"
487
+ onClick={handleDisconnect}
488
+ disabled={disabled || isBusy}
489
+ >
490
+ Reset
491
+ </button>
492
+ ) : null}
493
+ </div>
494
+ </section>
495
+ );
496
+ }
@@ -5,6 +5,7 @@ import { useState } from "react";
5
5
  export function OrchestrationGraphEmptyCanvas({
6
6
  onStartFromRegistry,
7
7
  onStartBlank,
8
+ onStartAgentSwarm,
8
9
  onPasteGraph,
9
10
  disabled
10
11
  }) {
@@ -23,6 +24,11 @@ export function OrchestrationGraphEmptyCanvas({
23
24
  <button type="button" className="dm-btn-outline" disabled={disabled} onClick={onStartBlank}>
24
25
  Start blank
25
26
  </button>
27
+ {onStartAgentSwarm && (
28
+ <button type="button" className="dm-btn-outline" disabled={disabled} onClick={onStartAgentSwarm}>
29
+ Add Agent Swarm
30
+ </button>
31
+ )}
26
32
  </div>
27
33
  <details
28
34
  className="dm-orchestration-canvas__paste"
@@ -897,7 +897,94 @@ export function OrchestrationNodeConfigPanel({
897
897
  </div>
898
898
  )}
899
899
 
900
- {activeTab === "configuration" && type === "ai-agent" && (
900
+ {activeTab === "configuration" && type === "ai-agent" && (config.role || config.taskPrompt || config.required != null) && (
901
+ <div className="dm-orchestration-config__pane">
902
+ <label className="dm-orchestration-config__field">
903
+ <span>Role</span>
904
+ <input
905
+ value={config.role || node.label || ""}
906
+ disabled={disabled}
907
+ onChange={(e) => patchConfig({ role: e.target.value, __nodePatch: { label: e.target.value } })}
908
+ />
909
+ </label>
910
+ <label className="dm-orchestration-config__field">
911
+ <span>Description</span>
912
+ <input
913
+ placeholder="One-sentence charter"
914
+ value={config.description || ""}
915
+ disabled={disabled}
916
+ onChange={(e) => patchConfig({ description: e.target.value })}
917
+ />
918
+ </label>
919
+ <label className="dm-orchestration-config__field">
920
+ <span>Task</span>
921
+ <textarea
922
+ rows={4}
923
+ value={config.taskPrompt || ""}
924
+ disabled={disabled}
925
+ onChange={(e) => patchConfig({ taskPrompt: e.target.value })}
926
+ />
927
+ </label>
928
+ <label className="dm-orchestration-config__field">
929
+ <span>Tools</span>
930
+ <input
931
+ placeholder="read, summarize"
932
+ value={Array.isArray(config.tools) ? config.tools.join(", ") : ""}
933
+ disabled={disabled}
934
+ onChange={(e) => patchConfig({
935
+ tools: e.target.value.split(",").map((t) => t.trim()).filter(Boolean)
936
+ })}
937
+ />
938
+ </label>
939
+ <label className="dm-orchestration-config__field">
940
+ <span>Max tokens</span>
941
+ <input
942
+ type="number"
943
+ min="0"
944
+ placeholder="0 = inherit"
945
+ value={config.maxTokens || 0}
946
+ disabled={disabled}
947
+ onChange={(e) => patchConfig({ maxTokens: Math.max(0, Number(e.target.value) || 0) })}
948
+ />
949
+ </label>
950
+ <label className="dm-orchestration-config__field">
951
+ <span>Agent host</span>
952
+ <select
953
+ value={config.agentHost || ""}
954
+ disabled={disabled}
955
+ onChange={(e) => patchConfig({ agentHost: e.target.value })}
956
+ >
957
+ <option value="">Inherit</option>
958
+ {Object.entries(HOST_AUTH_CATALOG || {}).map(([slug, host]) => (
959
+ <option key={slug} value={slug}>{host?.label || slug}</option>
960
+ ))}
961
+ </select>
962
+ </label>
963
+ <label className="dm-orchestration-config__field dm-orchestration-config__field-inline">
964
+ <input
965
+ type="checkbox"
966
+ checked={config.required !== false}
967
+ disabled={disabled}
968
+ onChange={(e) => patchConfig({ required: e.target.checked })}
969
+ />
970
+ <span>Required</span>
971
+ </label>
972
+ <label
973
+ className="dm-orchestration-config__field dm-orchestration-config__field-inline"
974
+ title="Network is granted only when both this and the row's networkAllow are on."
975
+ >
976
+ <input
977
+ type="checkbox"
978
+ checked={config.networkAccess === true}
979
+ disabled={disabled}
980
+ onChange={(e) => patchConfig({ networkAccess: e.target.checked })}
981
+ />
982
+ <span>Network</span>
983
+ </label>
984
+ </div>
985
+ )}
986
+
987
+ {activeTab === "configuration" && type === "ai-agent" && !(config.role || config.taskPrompt || config.required != null) && (
901
988
  <div className="dm-orchestration-config__pane">
902
989
  <label className="dm-orchestration-config__field">
903
990
  <span>Model</span>
@@ -215,6 +215,43 @@ async function copyToClipboard(text) {
215
215
  }
216
216
  }
217
217
 
218
+ function formatRewardScore(value) {
219
+ const n = Number(value);
220
+ if (!Number.isFinite(n)) return "—";
221
+ return n.toFixed(2);
222
+ }
223
+
224
+ function SwarmSection({ swarm }) {
225
+ if (!swarm || typeof swarm !== "object") return null;
226
+ const tasks = Array.isArray(swarm.tasks) ? swarm.tasks : [];
227
+ if (tasks.length === 0 && !swarm.orchestrator?.plan && !swarm.synthesis?.answer) return null;
228
+ const completed = tasks.filter((t) => t?.status === "completed").length;
229
+ const score = swarm.reward ? formatRewardScore(swarm.reward.score) : "—";
230
+ const kind = swarm.reward?.kind || "structural-v1";
231
+ const synthesis = swarm.synthesis || null;
232
+ return (
233
+ <section className="dm-run-console__section">
234
+ <h3>Swarm</h3>
235
+ <p className="dm-swarm-summary__line">
236
+ <span><strong>{completed}/{tasks.length}</strong></span>
237
+ <span>score <strong>{score}</strong></span>
238
+ <span className="dm-swarm-summary__kind" title={swarm.reward?.note || ""}>{kind}</span>
239
+ </p>
240
+ {synthesis?.answer ? (
241
+ <details className="dm-swarm-phase" open>
242
+ <summary>
243
+ synthesizer
244
+ {synthesis.parsedOutcomeScore != null
245
+ ? ` · ${Number(synthesis.parsedOutcomeScore).toFixed(2)}`
246
+ : ""}
247
+ </summary>
248
+ <pre>{synthesis.answer}</pre>
249
+ </details>
250
+ ) : null}
251
+ </section>
252
+ );
253
+ }
254
+
218
255
  function InputsSection({ payload }) {
219
256
  const runInputs = payload?.runInputs;
220
257
  const summary = payload?.inputSummary;
@@ -477,6 +514,9 @@ export function OrchestrationRunTracePanel({
477
514
  const payload = activeConsoleRecord?.payload || {};
478
515
  const output = activeConsoleRecord?.output || {};
479
516
  const context = activeConsoleRecord?.context || {};
517
+ const swarmPayload = activeConsoleRecord?.swarm
518
+ || (activeRawRecord && activeRawRecord.swarm)
519
+ || null;
480
520
 
481
521
  return (
482
522
  <section className="dm-run-console" aria-label="Live runs console">
@@ -710,6 +750,7 @@ export function OrchestrationRunTracePanel({
710
750
  <CodeBlock label="Command" body={payload.command} />
711
751
  <CodeBlock label="Instructions" body={payload.instructions} />
712
752
  </section>
753
+ <SwarmSection swarm={swarmPayload} />
713
754
  <InputsSection payload={payload} />
714
755
  </div>
715
756
  )}