@growthub/cli 0.13.0 → 0.13.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 (27) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +50 -25
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +38 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +522 -35
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +242 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +52 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +1203 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +163 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +190 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +64 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolDraftPanel.jsx +376 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +6 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1062 -2
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +10 -7
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +906 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/page.jsx +12 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +492 -28
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +114 -30
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/nav-workflows.js +54 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +322 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +734 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +73 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-sidecar-routing.js +24 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +2 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +21 -1
  27. package/package.json +1 -1
@@ -0,0 +1,906 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter, useSearchParams } from "next/navigation";
6
+ import {
7
+ Bot,
8
+ ChevronDown,
9
+ ChevronUp,
10
+ Code,
11
+ Filter,
12
+ FormInput,
13
+ GitBranch,
14
+ Globe2,
15
+ History,
16
+ MailPlus,
17
+ Pause,
18
+ PencilLine,
19
+ Play,
20
+ Plus,
21
+ Power,
22
+ RefreshCw,
23
+ Save,
24
+ Search,
25
+ Send,
26
+ Trash2,
27
+ X
28
+ } from "lucide-react";
29
+ import { WorkspaceRail } from "../workspace-rail.jsx";
30
+ import { findSandboxRowByWorkflowRef } from "@/lib/nav-workflows";
31
+ import {
32
+ addCanonicalNodeToGraph,
33
+ buildBlankOrchestrationGraphShell,
34
+ buildDefaultOrchestrationGraphFromRegistry,
35
+ getNextCanonicalNodeId,
36
+ getOrchestrationGraphUiState,
37
+ parseOrchestrationGraph,
38
+ redactSecretsFromText,
39
+ serializeOrchestrationGraph,
40
+ updateGraphNode,
41
+ validateOrchestrationGraph
42
+ } from "@/lib/orchestration-graph";
43
+ import { resolveConnectorAction } from "@/lib/orchestration-sidecar-routing";
44
+ import { OrchestrationGraphCanvas } from "../data-model/components/OrchestrationGraphCanvas.jsx";
45
+ import { OrchestrationGraphEmptyCanvas } from "../data-model/components/OrchestrationGraphEmptyCanvas.jsx";
46
+ import { OrchestrationNodeConfigPanel } from "../data-model/components/OrchestrationNodeConfigPanel.jsx";
47
+ import { OrchestrationRunTracePanel } from "../data-model/components/OrchestrationRunTracePanel.jsx";
48
+
49
+ function resolveRegistryRowForSandbox(workspaceConfig, sandboxRow) {
50
+ const graph = parseOrchestrationGraph(sandboxRow?.orchestrationGraph);
51
+ const apiNode = graph?.nodes?.find((n) => n?.type === "api-registry-call");
52
+ const registryId = String(
53
+ apiNode?.config?.registryId || apiNode?.config?.integrationId || sandboxRow?.schedulerRegistryId || ""
54
+ ).trim();
55
+ if (!registryId || !workspaceConfig) return null;
56
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
57
+ for (const objectItem of objects) {
58
+ if (objectItem?.objectType !== "api-registry") continue;
59
+ const rows = Array.isArray(objectItem.rows) ? objectItem.rows : [];
60
+ const match = rows.find((r) => String(r?.integrationId || "").trim() === registryId);
61
+ if (match) return match;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function patchSandboxRowInConfig(workspaceConfig, objectId, rowIndex, fields) {
67
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
68
+ return {
69
+ ...workspaceConfig,
70
+ dataModel: {
71
+ ...workspaceConfig.dataModel,
72
+ objects: objects.map((object) => {
73
+ if (object?.id !== objectId) return object;
74
+ const rows = Array.isArray(object.rows) ? object.rows : [];
75
+ return {
76
+ ...object,
77
+ rows: rows.map((row, index) => (index === rowIndex ? { ...row, ...fields } : row)),
78
+ };
79
+ }),
80
+ },
81
+ };
82
+ }
83
+
84
+ const WORKFLOW_ACTION_GROUPS = [
85
+ {
86
+ label: "Data",
87
+ items: [
88
+ { id: "create-record", label: "Create Record", type: "data-action", Icon: Plus, destructive: false },
89
+ { id: "update-record", label: "Update Record", type: "data-action", Icon: RefreshCw, destructive: false },
90
+ { id: "delete-record", label: "Delete Record", type: "data-action", Icon: Trash2, destructive: true },
91
+ { id: "search-records", label: "Search Records", type: "data-action", Icon: Search, destructive: false },
92
+ { id: "upsert-record", label: "Create or Update Record", type: "data-action", Icon: PencilLine, destructive: false },
93
+ ],
94
+ },
95
+ { label: "AI", items: [{ id: "ai-agent", label: "AI Agent", type: "ai-agent", Icon: Bot, destructive: false }] },
96
+ {
97
+ label: "Flow",
98
+ items: [
99
+ { id: "iterator", label: "Iterator", type: "flow-control", Icon: RefreshCw, destructive: false },
100
+ { id: "filter", label: "Filter", type: "flow-control", Icon: Filter, destructive: false },
101
+ { id: "if-else", label: "If/else", type: "flow-control", Icon: GitBranch, destructive: false },
102
+ { id: "delay", label: "Delay", type: "flow-control", Icon: Pause, destructive: false },
103
+ ],
104
+ },
105
+ {
106
+ label: "Core",
107
+ items: [
108
+ { id: "send-email", label: "Send Email", type: "core-action", Icon: Send, destructive: false },
109
+ { id: "draft-email", label: "Draft Email", type: "core-action", Icon: MailPlus, destructive: false },
110
+ { id: "code-function", label: "Code - Logic Function", type: "core-action", Icon: Code, destructive: false },
111
+ { id: "http-request", label: "HTTP Request", type: "core-action", Icon: Globe2, destructive: false },
112
+ ],
113
+ },
114
+ { label: "Human Input", items: [{ id: "form", label: "Form", type: "human-input", Icon: FormInput, destructive: false }] },
115
+ ];
116
+
117
+ function getWorkspaceObjectOptions(workspaceConfig) {
118
+ return (Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [])
119
+ .filter((object) => object?.id && object?.objectType !== "sandbox-environment" && object?.objectType !== "api-registry")
120
+ .map((object) => ({
121
+ id: String(object.id),
122
+ label: String(object.name || object.label || object.id),
123
+ objectType: String(object.objectType || "custom")
124
+ }));
125
+ }
126
+
127
+ function normalizeDeltaTags(tags) {
128
+ return Array.from(new Set((Array.isArray(tags) ? tags : [])
129
+ .map((tag) => String(tag || "").trim().toLowerCase())
130
+ .filter(Boolean)));
131
+ }
132
+
133
+ function inferDeltaTagsForWorkflowNode(node, config) {
134
+ const tags = [];
135
+ const type = String(node?.type || "").trim();
136
+ const action = String(config?.action || node?.id || "").trim();
137
+ if (type === "thinAdapter") tags.push("model", "prompt", "routing");
138
+ if (type === "ai-agent") tags.push("model", "prompt", "output");
139
+ if (type === "data-action" || type === "data-trigger") tags.push("input", "output");
140
+ if (type === "flow-control") tags.push("routing");
141
+ if (type === "core-action") tags.push("runtime");
142
+ if (type === "human-input") tags.push("input");
143
+ if (action.includes("search") || action.includes("filter")) tags.push("evaluation", "guardrail");
144
+ if (action.includes("delete") || config?.confirmationRequired) tags.push("guardrail");
145
+ if (action.includes("http") || config?.url || config?.method) tags.push("routing", "input", "output");
146
+ if (action.includes("email")) tags.push("input", "output");
147
+ if (action.includes("delay") || config?.duration || config?.unit) tags.push("runtime");
148
+ if (config?.objectId || config?.fieldMap || config?.filters) tags.push("input", "output");
149
+ if (config?.model || config?.prompt) tags.push("model", "prompt");
150
+ return normalizeDeltaTags(tags);
151
+ }
152
+
153
+ function getNodeDeltaRecords(previousGraph, nextGraph) {
154
+ const previousNodes = new Map(
155
+ (Array.isArray(previousGraph?.nodes) ? previousGraph.nodes : [])
156
+ .map((node) => [String(node?.id || ""), node])
157
+ .filter(([id]) => id)
158
+ );
159
+
160
+ return (Array.isArray(nextGraph?.nodes) ? nextGraph.nodes : [])
161
+ .map((node) => {
162
+ const nodeId = String(node?.id || "").trim();
163
+ if (!nodeId) return null;
164
+ const previous = previousNodes.get(nodeId);
165
+ const config = node?.config && typeof node.config === "object" && !Array.isArray(node.config) ? node.config : {};
166
+ const previousConfig = previous?.config && typeof previous.config === "object" && !Array.isArray(previous.config)
167
+ ? previous.config
168
+ : {};
169
+ const currentComparable = JSON.stringify({
170
+ type: node?.type || "",
171
+ sandbox: node?.sandbox || "",
172
+ label: node?.label || "",
173
+ subtitle: node?.subtitle || "",
174
+ config
175
+ });
176
+ const previousComparable = JSON.stringify({
177
+ type: previous?.type || "",
178
+ sandbox: previous?.sandbox || "",
179
+ label: previous?.label || "",
180
+ subtitle: previous?.subtitle || "",
181
+ config: previousConfig
182
+ });
183
+ const explicitTags = normalizeDeltaTags(config.deltaTags);
184
+ const deltaTags = explicitTags.length > 0 ? explicitTags : inferDeltaTagsForWorkflowNode(node, config);
185
+ const changeReason = String(config.changeReason || "").trim();
186
+ const changed = currentComparable !== previousComparable;
187
+ if (!changed && !changeReason && deltaTags.length === 0) return null;
188
+ return {
189
+ nodeId,
190
+ nodeType: String(node?.type || ""),
191
+ label: String(node?.label || node?.sandbox || nodeId),
192
+ changeReason,
193
+ deltaTags,
194
+ requiresRetest: config.requiresRetest !== false,
195
+ previous: previous ? {
196
+ type: String(previous.type || ""),
197
+ sandbox: String(previous.sandbox || ""),
198
+ label: String(previous.label || "")
199
+ } : null,
200
+ next: {
201
+ type: String(node.type || ""),
202
+ sandbox: String(node.sandbox || ""),
203
+ label: String(node.label || "")
204
+ }
205
+ };
206
+ })
207
+ .filter(Boolean);
208
+ }
209
+
210
+ function makeWorkflowNode(action, workspaceConfig, graph) {
211
+ const baseId = String(action.id || action.type || "step").replace(/[^a-zA-Z0-9_-]+/g, "-");
212
+ const existingIds = new Set((Array.isArray(graph?.nodes) ? graph.nodes : []).map((node) => String(node.id)));
213
+ let id = baseId;
214
+ let index = 2;
215
+ while (existingIds.has(id)) {
216
+ id = `${baseId}-${index}`;
217
+ index += 1;
218
+ }
219
+ const isData = action.type === "data-action" || action.type === "data-trigger";
220
+ return {
221
+ id,
222
+ type: action.type,
223
+ label: action.label,
224
+ subtitle: isData ? "Select workspace object" : action.type,
225
+ config: {
226
+ action: action.id,
227
+ destructive: Boolean(action.destructive),
228
+ objectId: "",
229
+ objectType: "",
230
+ objectName: "",
231
+ confirmationRequired: Boolean(action.destructive),
232
+ mode: "draft"
233
+ }
234
+ };
235
+ }
236
+
237
+ function insertWorkflowNode(graph, node, target = {}) {
238
+ const parsed = parseOrchestrationGraph(graph) || graph || buildBlankOrchestrationGraphShell();
239
+ const nodes = Array.isArray(parsed.nodes) ? [...parsed.nodes, node] : [node];
240
+ const edges = Array.isArray(parsed.edges) ? [...parsed.edges] : [];
241
+ const from = String(target.from || "").trim();
242
+ const to = String(target.to || "").trim();
243
+ const filteredEdges = from && to ? edges.filter((edge) => !(String(edge.from) === from && String(edge.to) === to)) : edges;
244
+ if (from) filteredEdges.push({ from, to: node.id, passes: "workflow-delta" });
245
+ if (to) filteredEdges.push({ from: node.id, to, passes: "workflow-delta" });
246
+ return { ...parsed, nodes, edges: filteredEdges };
247
+ }
248
+
249
+ function removeWorkflowNode(graph, nodeId) {
250
+ const parsed = parseOrchestrationGraph(graph) || graph || buildBlankOrchestrationGraphShell();
251
+ const id = String(nodeId || "").trim();
252
+ if (!id) return parsed;
253
+ return {
254
+ ...parsed,
255
+ nodes: (Array.isArray(parsed.nodes) ? parsed.nodes : []).filter((node) => String(node.id) !== id),
256
+ edges: (Array.isArray(parsed.edges) ? parsed.edges : []).filter(
257
+ (edge) => String(edge.from) !== id && String(edge.to) !== id
258
+ )
259
+ };
260
+ }
261
+
262
+ function getRunHttpStatus(responseText) {
263
+ try {
264
+ const parsed = typeof responseText === "string" ? JSON.parse(responseText) : responseText;
265
+ const status = parsed?.adapterMeta?.httpStatus ?? parsed?.response?.adapterMeta?.httpStatus ?? parsed?.httpStatus;
266
+ const number = Number(status);
267
+ return Number.isFinite(number) ? number : null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ function isPassingRun(payload) {
274
+ const httpStatus = getRunHttpStatus(payload?.response);
275
+ if (httpStatus != null) return payload?.ok === true && httpStatus === 200;
276
+ return payload?.ok === true && Number(payload?.exitCode ?? payload?.response?.exitCode) === 0;
277
+ }
278
+
279
+ function WorkflowAddStepPanel({ target, onSelect }) {
280
+ return (
281
+ <div className="dm-workflow-add-panel">
282
+ <div className="dm-workflow-add-panel__context">
283
+ <span>Insert step</span>
284
+ <strong>{target?.from ? `After ${target.from}` : "At end of workflow"}</strong>
285
+ {target?.to && <em>Before {target.to}</em>}
286
+ </div>
287
+ {WORKFLOW_ACTION_GROUPS.map((group) => (
288
+ <div key={group.label} className="dm-workflow-action-group">
289
+ <span className="dm-workflow-action-group__label">{group.label}</span>
290
+ {group.items.map((item) => {
291
+ const Icon = item.Icon;
292
+ return (
293
+ <button key={item.id} type="button" className="dm-workflow-action-option" onClick={() => onSelect(item)}>
294
+ <span aria-hidden="true"><Icon size={16} /></span>
295
+ <strong>{item.label}</strong>
296
+ {item.destructive && <small>Requires confirmation at run time</small>}
297
+ </button>
298
+ );
299
+ })}
300
+ </div>
301
+ ))}
302
+ </div>
303
+ );
304
+ }
305
+
306
+ export default function WorkflowSurface() {
307
+ const router = useRouter();
308
+ const searchParams = useSearchParams();
309
+ const objectId = String(searchParams.get("object") || "").trim();
310
+ const rowId = String(searchParams.get("row") || "").trim();
311
+ const fieldName = String(searchParams.get("field") || "orchestrationGraph").trim();
312
+ const runId = String(searchParams.get("run") || "").trim();
313
+
314
+ const [workspaceConfig, setWorkspaceConfig] = useState(null);
315
+ const [authority, setAuthority] = useState(null);
316
+ const [loading, setLoading] = useState(true);
317
+ const [error, setError] = useState("");
318
+ const [saving, setSaving] = useState(false);
319
+ const [publishing, setPublishing] = useState(false);
320
+ const [saveMessage, setSaveMessage] = useState("");
321
+ const [running, setRunning] = useState(false);
322
+ const [runMessage, setRunMessage] = useState("");
323
+ const [sidecarMode, setSidecarMode] = useState(runId ? "trace" : "graph");
324
+
325
+ const [selectedNodeId, setSelectedNodeId] = useState("");
326
+ const [addTarget, setAddTarget] = useState(null);
327
+ const [configTab, setConfigTab] = useState("node");
328
+ const [graphError, setGraphError] = useState("");
329
+ const [orchestrationGraph, setOrchestrationGraph] = useState(null);
330
+ const [dirty, setDirty] = useState(false);
331
+
332
+ const load = useCallback(async () => {
333
+ setLoading(true);
334
+ setError("");
335
+ try {
336
+ const res = await fetch("/api/workspace", { cache: "no-store" });
337
+ const payload = await res.json();
338
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
339
+ setWorkspaceConfig(payload.workspaceConfig);
340
+ setAuthority(payload.adapters?.integrations?.authority || null);
341
+ } catch (err) {
342
+ setError(err.message || "Failed to load workspace");
343
+ } finally {
344
+ setLoading(false);
345
+ }
346
+ }, []);
347
+
348
+ useEffect(() => { load(); }, [load]);
349
+
350
+ const resolved = useMemo(
351
+ () => (workspaceConfig ? findSandboxRowByWorkflowRef(workspaceConfig, objectId, rowId) : { object: null, row: null, rowIndex: -1 }),
352
+ [workspaceConfig, objectId, rowId]
353
+ );
354
+
355
+ const sandboxRow = resolved.row;
356
+ const effectiveFieldName = sandboxRow?.[fieldName] !== undefined
357
+ ? fieldName
358
+ : sandboxRow?.orchestrationConfig !== undefined
359
+ ? "orchestrationConfig"
360
+ : "orchestrationGraph";
361
+ const draftFieldName = effectiveFieldName === "orchestrationConfig" ? "orchestrationDraftConfig" : "orchestrationDraftGraph";
362
+ const registryRow = useMemo(
363
+ () => (sandboxRow && workspaceConfig ? resolveRegistryRowForSandbox(workspaceConfig, sandboxRow) : null),
364
+ [workspaceConfig, sandboxRow]
365
+ );
366
+
367
+ useEffect(() => {
368
+ setSidecarMode(runId ? "trace" : "graph");
369
+ }, [runId]);
370
+
371
+ useEffect(() => {
372
+ if (!sandboxRow) return;
373
+ const parsed = parseOrchestrationGraph(sandboxRow[draftFieldName]) || parseOrchestrationGraph(sandboxRow[effectiveFieldName]);
374
+ setOrchestrationGraph(parsed);
375
+ setDirty(false);
376
+ setGraphError("");
377
+ }, [sandboxRow, effectiveFieldName, draftFieldName, objectId, rowId]);
378
+
379
+ const graphUiState = getOrchestrationGraphUiState(orchestrationGraph);
380
+ const graphUnset = graphUiState === "unset";
381
+ const graphBlankShell = graphUiState === "blank-shell";
382
+ const nextNodeId = useMemo(
383
+ () => (orchestrationGraph ? getNextCanonicalNodeId(orchestrationGraph) : "input"),
384
+ [orchestrationGraph]
385
+ );
386
+
387
+ const selectedNode = useMemo(() => {
388
+ if (!orchestrationGraph?.nodes || !selectedNodeId) return null;
389
+ return orchestrationGraph.nodes.find((n) => String(n.id) === selectedNodeId) || null;
390
+ }, [orchestrationGraph, selectedNodeId]);
391
+
392
+ useEffect(() => {
393
+ if (graphUnset || graphBlankShell) {
394
+ setGraphError("");
395
+ return;
396
+ }
397
+ const validation = validateOrchestrationGraph(orchestrationGraph);
398
+ setGraphError(validation.ok ? "" : validation.errors[0] || "Invalid graph");
399
+ }, [orchestrationGraph, graphUnset, graphBlankShell]);
400
+
401
+ async function persistWorkspace(nextConfig) {
402
+ const res = await fetch("/api/workspace", {
403
+ method: "PATCH",
404
+ headers: { "content-type": "application/json" },
405
+ body: JSON.stringify({ dataModel: nextConfig.dataModel }),
406
+ });
407
+ const payload = await res.json();
408
+ if (!res.ok) throw new Error(payload.error || "Failed to save workspace");
409
+ setWorkspaceConfig(payload.workspaceConfig || nextConfig);
410
+ }
411
+
412
+ function serializeCurrentGraph() {
413
+ return graphUnset ? "" : serializeOrchestrationGraph(orchestrationGraph);
414
+ }
415
+
416
+ async function saveDraft(extraFields = {}) {
417
+ if (resolved.rowIndex < 0 || !objectId) return null;
418
+ const serialized = serializeCurrentGraph();
419
+ const next = patchSandboxRowInConfig(workspaceConfig, objectId, resolved.rowIndex, {
420
+ [draftFieldName]: serialized,
421
+ orchestrationDraftStatus: "draft",
422
+ orchestrationDraftUpdatedAt: new Date().toISOString(),
423
+ orchestrationDraftBaseVersion: String(sandboxRow?.version || "1"),
424
+ ...extraFields
425
+ });
426
+ await persistWorkspace(next);
427
+ return { next, serialized };
428
+ }
429
+
430
+ async function saveGraph() {
431
+ if (resolved.rowIndex < 0 || !objectId) return;
432
+ setSaving(true);
433
+ setSaveMessage("");
434
+ try {
435
+ await saveDraft({
436
+ orchestrationDraftStatus: "untested",
437
+ orchestrationDraftTestPassed: false,
438
+ orchestrationDraftTestedConfig: ""
439
+ });
440
+ setDirty(false);
441
+ setSaveMessage("Saved draft changes. Test must pass before Publish can update the executable version.");
442
+ } catch (err) {
443
+ setSaveMessage(err.message || "Save failed");
444
+ } finally {
445
+ setSaving(false);
446
+ }
447
+ }
448
+
449
+ async function publishGraph() {
450
+ if (resolved.rowIndex < 0 || !objectId) return;
451
+ const serialized = serializeCurrentGraph();
452
+ const draftPassed = sandboxRow?.orchestrationDraftTestPassed === true || String(sandboxRow?.orchestrationDraftTestPassed || "") === "true";
453
+ const testedConfig = String(sandboxRow?.orchestrationDraftTestedConfig || "");
454
+ if (!draftPassed || testedConfig !== serialized) {
455
+ setSaveMessage("Publish blocked. Save and test this exact draft successfully before publishing.");
456
+ return;
457
+ }
458
+ setPublishing(true);
459
+ setSaveMessage("");
460
+ try {
461
+ const currentVersion = Number(sandboxRow?.version || 1);
462
+ const nextVersion = Number.isFinite(currentVersion) ? String(currentVersion + 1) : "1";
463
+ const previousDeltas = Array.isArray(sandboxRow?.orchestrationDeltas) ? sandboxRow.orchestrationDeltas : [];
464
+ const previousPublishedGraph = parseOrchestrationGraph(sandboxRow?.[effectiveFieldName]);
465
+ const nodeDeltas = getNodeDeltaRecords(previousPublishedGraph, orchestrationGraph);
466
+ const deltaTags = normalizeDeltaTags(nodeDeltas.flatMap((delta) => delta.deltaTags));
467
+ const changeReason = nodeDeltas.map((delta) => delta.changeReason).filter(Boolean).join("\n");
468
+ const next = patchSandboxRowInConfig(workspaceConfig, objectId, resolved.rowIndex, {
469
+ [effectiveFieldName]: serialized,
470
+ [draftFieldName]: "",
471
+ version: nextVersion,
472
+ lifecycleStatus: "live",
473
+ orchestrationDraftStatus: "published",
474
+ orchestrationDraftTestPassed: false,
475
+ orchestrationDraftTestedConfig: "",
476
+ orchestrationPublishedAt: new Date().toISOString(),
477
+ orchestrationDeltas: [
478
+ ...previousDeltas,
479
+ {
480
+ at: new Date().toISOString(),
481
+ version: nextVersion,
482
+ field: effectiveFieldName,
483
+ action: "publish",
484
+ previousVersion: String(sandboxRow?.version || "1"),
485
+ draftTestedAt: sandboxRow?.orchestrationDraftLastTested || "",
486
+ draftRunId: sandboxRow?.orchestrationDraftLastRunId || "",
487
+ changeReason,
488
+ deltaTags,
489
+ nodeDeltas,
490
+ nodeCount: Array.isArray(orchestrationGraph?.nodes) ? orchestrationGraph.nodes.length : 0,
491
+ edgeCount: Array.isArray(orchestrationGraph?.edges) ? orchestrationGraph.edges.length : 0
492
+ }
493
+ ]
494
+ });
495
+ await persistWorkspace(next);
496
+ setDirty(false);
497
+ setSaveMessage(`Published orchestration config v${nextVersion}.`);
498
+ } catch (err) {
499
+ setSaveMessage(err.message || "Publish failed");
500
+ } finally {
501
+ setPublishing(false);
502
+ }
503
+ }
504
+
505
+ async function discardDraft() {
506
+ if (resolved.rowIndex < 0 || !objectId) return;
507
+ const hasSavedDraft = Boolean(String(sandboxRow?.[draftFieldName] || "").trim());
508
+ if (hasSavedDraft || dirty) {
509
+ const confirmed = window.confirm("Discard draft changes and return to the latest published orchestration config?");
510
+ if (!confirmed) return;
511
+ }
512
+ setSaving(true);
513
+ setSaveMessage("");
514
+ try {
515
+ const next = patchSandboxRowInConfig(workspaceConfig, objectId, resolved.rowIndex, {
516
+ [draftFieldName]: "",
517
+ orchestrationDraftStatus: "",
518
+ orchestrationDraftUpdatedAt: "",
519
+ orchestrationDraftBaseVersion: "",
520
+ orchestrationDraftLastTested: "",
521
+ orchestrationDraftLastRunId: "",
522
+ orchestrationDraftLastResponse: "",
523
+ orchestrationDraftTestPassed: false,
524
+ orchestrationDraftTestedConfig: ""
525
+ });
526
+ await persistWorkspace(next);
527
+ setOrchestrationGraph(parseOrchestrationGraph(sandboxRow?.[effectiveFieldName]));
528
+ setSelectedNodeId("");
529
+ setAddTarget(null);
530
+ setDirty(false);
531
+ setSaveMessage("Draft discarded. Showing latest published workflow.");
532
+ } catch (err) {
533
+ setSaveMessage(err.message || "Discard failed");
534
+ } finally {
535
+ setSaving(false);
536
+ }
537
+ }
538
+
539
+ async function runSandbox() {
540
+ if (!objectId || !rowId) return;
541
+ setRunning(true);
542
+ setRunMessage("");
543
+ try {
544
+ const draft = await saveDraft({ orchestrationDraftStatus: "testing" });
545
+ const draftGraph = draft?.serialized || serializeCurrentGraph();
546
+ const res = await fetch("/api/workspace/sandbox-run", {
547
+ method: "POST",
548
+ headers: { "content-type": "application/json" },
549
+ body: JSON.stringify({ objectId, name: rowId, useDraft: true, draftGraph }),
550
+ });
551
+ const payload = await res.json();
552
+ const responseText = redactSecretsFromText(JSON.stringify(payload.response ?? payload, null, 2));
553
+ const status = payload.ok && String(payload.status || "").toLowerCase() === "connected" ? "connected" : "failed";
554
+ const pass = isPassingRun(payload);
555
+ const testedAt = payload.response?.ranAt || new Date().toISOString();
556
+ const lastRunId = payload.runId || payload.response?.runId || "";
557
+ const lastSourceId = payload.sourceId || payload.response?.sourceId || "";
558
+ const next = patchSandboxRowInConfig(draft?.next || workspaceConfig, objectId, resolved.rowIndex, {
559
+ [draftFieldName]: draftGraph,
560
+ status,
561
+ lastTested: testedAt,
562
+ lastRunId,
563
+ lastSourceId,
564
+ lastResponse: responseText,
565
+ orchestrationDraftStatus: pass ? "tested" : "failed",
566
+ orchestrationDraftLastTested: testedAt,
567
+ orchestrationDraftLastRunId: lastRunId,
568
+ orchestrationDraftLastResponse: responseText,
569
+ orchestrationDraftTestPassed: pass,
570
+ orchestrationDraftTestedConfig: pass ? draftGraph : "",
571
+ });
572
+ await persistWorkspace(next);
573
+ setDirty(false);
574
+ setRunMessage(pass ? "Draft test passed. Publish is now available." : redactSecretsFromText(payload.response?.error || payload.error || "Draft test failed. Publish remains blocked."));
575
+ } catch (err) {
576
+ setRunMessage(redactSecretsFromText(err.message || "Sandbox run failed"));
577
+ } finally {
578
+ setRunning(false);
579
+ }
580
+ }
581
+
582
+ function openTraceMode() {
583
+ const params = new URLSearchParams(searchParams.toString());
584
+ params.delete("run");
585
+ router.push(`/workflows?${params.toString()}`);
586
+ setSidecarMode("trace");
587
+ }
588
+
589
+ function openGraphMode() {
590
+ const params = new URLSearchParams(searchParams.toString());
591
+ params.delete("run");
592
+ router.push(`/workflows?${params.toString()}`);
593
+ setSidecarMode("graph");
594
+ }
595
+
596
+ function startFromRegistry() {
597
+ if (!registryRow) return;
598
+ setOrchestrationGraph(buildDefaultOrchestrationGraphFromRegistry(registryRow));
599
+ setSelectedNodeId("input");
600
+ setDirty(true);
601
+ }
602
+
603
+ function startBlank() {
604
+ setOrchestrationGraph(buildBlankOrchestrationGraphShell());
605
+ setSelectedNodeId("input");
606
+ setDirty(true);
607
+ }
608
+
609
+ function applyPastedGraph(text) {
610
+ const parsed = parseOrchestrationGraph(text);
611
+ if (parsed) {
612
+ setOrchestrationGraph(parsed);
613
+ setDirty(true);
614
+ }
615
+ }
616
+
617
+ function addNextNode() {
618
+ if (!nextNodeId) return;
619
+ setOrchestrationGraph((g) => addCanonicalNodeToGraph(
620
+ g || buildBlankOrchestrationGraphShell(),
621
+ nextNodeId,
622
+ registryRow || {},
623
+ ));
624
+ setSelectedNodeId(nextNodeId);
625
+ setDirty(true);
626
+ }
627
+
628
+ function insertActionNode(action) {
629
+ const node = makeWorkflowNode(action, workspaceConfig, orchestrationGraph);
630
+ setOrchestrationGraph((g) => insertWorkflowNode(g, node, addTarget || {}));
631
+ setSelectedNodeId(node.id);
632
+ setConfigTab("node");
633
+ setAddTarget(null);
634
+ setDirty(true);
635
+ }
636
+
637
+ function handleNodeConfigChange(configPatch) {
638
+ if (!selectedNodeId) return;
639
+ const { __nodePatch, ...configOnly } = configPatch || {};
640
+ setOrchestrationGraph((g) => {
641
+ const updated = updateGraphNode(g, selectedNodeId, configOnly);
642
+ if (!__nodePatch || typeof __nodePatch !== "object") return updated;
643
+ const parsed = parseOrchestrationGraph(updated) || updated;
644
+ return {
645
+ ...parsed,
646
+ nodes: (Array.isArray(parsed?.nodes) ? parsed.nodes : []).map((node) => (
647
+ String(node.id) === selectedNodeId ? { ...node, ...__nodePatch } : node
648
+ ))
649
+ };
650
+ });
651
+ setDirty(true);
652
+ }
653
+
654
+ function deleteSelectedNode() {
655
+ if (!selectedNodeId) return;
656
+ const label = selectedNode?.label || selectedNodeId;
657
+ const first = window.confirm(`Delete node "${label}" from the draft workflow?`);
658
+ if (!first) return;
659
+ const second = window.confirm("Confirm deletion. This changes the saved draft only and will not affect the published execution version until Publish.");
660
+ if (!second) return;
661
+ setOrchestrationGraph((g) => removeWorkflowNode(g, selectedNodeId));
662
+ setSelectedNodeId("");
663
+ setConfigTab("node");
664
+ setDirty(true);
665
+ }
666
+
667
+ function handleConnectorAction(payload) {
668
+ if (payload?.action === "add-step") {
669
+ setAddTarget({ from: String(payload.from || ""), to: String(payload.to || "") });
670
+ setSelectedNodeId("");
671
+ setConfigTab("node");
672
+ return;
673
+ }
674
+ if (payload?.action === "delete-edge-request") {
675
+ setAddTarget(null);
676
+ setSelectedNodeId("");
677
+ setRunMessage("Edge deletion requires confirmation and is not applied from the canvas.");
678
+ return;
679
+ }
680
+ const { nodeId, tab } = resolveConnectorAction(payload);
681
+ setSelectedNodeId(nodeId);
682
+ setConfigTab(tab);
683
+ }
684
+
685
+ const label = sandboxRow?.Name || rowId || "Workflow";
686
+ const lifecycle = String(sandboxRow?.lifecycleStatus || "draft").trim();
687
+ const version = String(sandboxRow?.version || "1").trim();
688
+ const nodeCount = Array.isArray(orchestrationGraph?.nodes) ? orchestrationGraph.nodes.length : 0;
689
+ const totalSteps = Math.max(nodeCount, 1);
690
+ const orderedNodes = orchestrationGraph?.nodes || [];
691
+ const currentGraphSerialized = graphUnset ? "" : serializeOrchestrationGraph(orchestrationGraph);
692
+ const draftPassed = sandboxRow?.orchestrationDraftTestPassed === true || String(sandboxRow?.orchestrationDraftTestPassed || "") === "true";
693
+ const publishReady = draftPassed && String(sandboxRow?.orchestrationDraftTestedConfig || "") === currentGraphSerialized && !dirty;
694
+ const savedDraftValue = String(sandboxRow?.[draftFieldName] || "").trim();
695
+ const draftStatus = String(sandboxRow?.orchestrationDraftStatus || "").trim();
696
+ const hasSavedDraft = Boolean(savedDraftValue) && draftStatus !== "published";
697
+ const isDraftMode = dirty || hasSavedDraft;
698
+ const canTest = !graphUnset && !graphBlankShell && Boolean(sandboxRow) && !Boolean(graphError);
699
+ const showDiscardDraft = isDraftMode;
700
+ const showPublish = isDraftMode || publishReady;
701
+ const showSaveDraft = dirty && !graphUnset;
702
+ const workflowModeLabel = isDraftMode ? "draft" : lifecycle || "live";
703
+
704
+ return (
705
+ <main className="workspace-builder dm-workflow-page">
706
+ <WorkspaceRail
707
+ workspaceConfig={workspaceConfig}
708
+ authority={authority}
709
+ helperOpen={false}
710
+ onConfigChange={(nextConfig) => setWorkspaceConfig(nextConfig)}
711
+ onOpenHelper={() => router.push("/data-model?helper=open")}
712
+ onOpenThread={(row) => router.push(`/data-model?thread=${encodeURIComponent(row.id)}`)}
713
+ />
714
+ <section className="workspace-surface dm-workflow-surface">
715
+ <header className="workspace-toolbar dm-workflow-toolbar">
716
+ <div className="dm-workflow-titlebar">
717
+ <span className="dm-workflow-title-muted">Workflows</span>
718
+ <span className="dm-workflow-title-separator">/</span>
719
+ <h1>{label}</h1>
720
+ <span className="dm-workflow-count">({nodeCount}/{totalSteps}) · v{version} · {workflowModeLabel}</span>
721
+ </div>
722
+ <div className="dm-workflow-toolbar-actions">
723
+ <button
724
+ type="button"
725
+ className="dm-workflow-icon-btn"
726
+ aria-label="Navigate to next Workflow"
727
+ onClick={() => {
728
+ if (!orderedNodes.length) return;
729
+ const index = Math.max(0, orderedNodes.findIndex((node) => String(node.id) === selectedNodeId));
730
+ const next = orderedNodes[(index + 1) % orderedNodes.length];
731
+ setSelectedNodeId(String(next?.id || ""));
732
+ setAddTarget(null);
733
+ }}
734
+ >
735
+ <ChevronDown size={14} />
736
+ </button>
737
+ <button
738
+ type="button"
739
+ className="dm-workflow-icon-btn"
740
+ aria-label="Navigate to previous Workflow"
741
+ onClick={() => {
742
+ if (!orderedNodes.length) return;
743
+ const index = Math.max(0, orderedNodes.findIndex((node) => String(node.id) === selectedNodeId));
744
+ const prev = orderedNodes[(index - 1 + orderedNodes.length) % orderedNodes.length];
745
+ setSelectedNodeId(String(prev?.id || ""));
746
+ setAddTarget(null);
747
+ }}
748
+ >
749
+ <ChevronUp size={14} />
750
+ </button>
751
+ {showDiscardDraft && (
752
+ <button
753
+ type="button"
754
+ className="dm-workflow-chip-btn"
755
+ disabled={saving || running || publishing}
756
+ onClick={discardDraft}
757
+ >
758
+ Discard Draft
759
+ </button>
760
+ )}
761
+ {canTest && (
762
+ <button type="button" className="dm-workflow-chip-btn" disabled={running || saving || publishing} onClick={runSandbox}>
763
+ <Play size={13} /> {running ? "Running" : "Test"}
764
+ </button>
765
+ )}
766
+ {showPublish && (
767
+ <button
768
+ type="button"
769
+ className="dm-workflow-chip-btn"
770
+ disabled={publishing || saving || running || !publishReady || Boolean(graphError) || graphUnset}
771
+ onClick={publishGraph}
772
+ title={publishReady ? "Publish tested draft" : "Save and pass Test before publishing"}
773
+ >
774
+ <Power size={13} /> {publishing ? "Publishing" : "Publish"}
775
+ </button>
776
+ )}
777
+ <button type="button" className="dm-workflow-chip-btn" disabled={!sandboxRow} onClick={openTraceMode}>
778
+ <History size={13} /> See Runs
779
+ </button>
780
+ {sidecarMode === "trace" && (
781
+ <button type="button" className="dm-workflow-chip-btn" onClick={openGraphMode}>
782
+ Edit graph
783
+ </button>
784
+ )}
785
+ {showSaveDraft && (
786
+ <button
787
+ type="button"
788
+ className="dm-workflow-chip-btn"
789
+ disabled={saving || running || publishing || Boolean(graphError) || graphUnset}
790
+ onClick={saveGraph}
791
+ >
792
+ <Save size={13} /> {saving ? "Saving" : "Save draft"}
793
+ </button>
794
+ )}
795
+ <Link href={`/data-model?object=${encodeURIComponent(objectId)}`} className="dm-workflow-icon-btn" aria-label="Back to Data Model">
796
+ <X size={14} />
797
+ </Link>
798
+ </div>
799
+ </header>
800
+
801
+ {loading ? (
802
+ <p className="dm-workflow-empty">Loading workflow…</p>
803
+ ) : error ? (
804
+ <p className="dm-workflow-empty dm-workflow-error">{error}</p>
805
+ ) : !objectId || !rowId ? (
806
+ <p className="dm-workflow-empty">Missing workflow object or row in the URL.</p>
807
+ ) : !sandboxRow ? (
808
+ <p className="dm-workflow-empty">
809
+ Sandbox row not found. The workflow shortcut may reference a removed row.
810
+ </p>
811
+ ) : sidecarMode === "trace" ? (
812
+ <OrchestrationRunTracePanel
813
+ row={sandboxRow}
814
+ objectId={objectId}
815
+ fieldName="lastResponse"
816
+ selectedRunId={runId}
817
+ onBack={openGraphMode}
818
+ onOpenGraph={openGraphMode}
819
+ />
820
+ ) : (
821
+ <div className={`dm-orchestration-sidecar dm-workflow-orchestration${selectedNode || addTarget ? " has-panel" : ""}`}>
822
+ <div className="dm-orchestration-sidecar__body">
823
+ <div className="dm-orchestration-sidecar__canvas-col">
824
+ {graphUnset ? (
825
+ <OrchestrationGraphEmptyCanvas
826
+ disabled={false}
827
+ onStartFromRegistry={registryRow ? startFromRegistry : undefined}
828
+ onStartBlank={startBlank}
829
+ onPasteGraph={applyPastedGraph}
830
+ />
831
+ ) : graphBlankShell ? (
832
+ <div className="dm-orchestration-canvas dm-orchestration-canvas--blank-shell">
833
+ <p className="dm-orchestration-canvas__blank-hint">Add first node</p>
834
+ <button type="button" className="dm-btn-outline" onClick={addNextNode}>
835
+ + Add Input
836
+ </button>
837
+ </div>
838
+ ) : (
839
+ <>
840
+ <OrchestrationGraphCanvas
841
+ graph={orchestrationGraph}
842
+ selectedNodeId={selectedNodeId}
843
+ onSelectNode={(node) => {
844
+ setSelectedNodeId(String(node?.id || ""));
845
+ setConfigTab("node");
846
+ }}
847
+ onConnectorAction={handleConnectorAction}
848
+ statusLabel={isDraftMode ? "Draft" : "Live"}
849
+ />
850
+ {nextNodeId && (
851
+ <button type="button" className="dm-btn-outline dm-orchestration-canvas__add-node" onClick={addNextNode}>
852
+ + Add {nextNodeId === "api-request" ? "API Registry" : nextNodeId}
853
+ </button>
854
+ )}
855
+ </>
856
+ )}
857
+ </div>
858
+ {graphUiState === "populated" && addTarget && (
859
+ <div className="dm-orchestration-sidecar__config-col">
860
+ <div className="dm-workflow-panel-head">
861
+ <button type="button" className="dm-workflow-icon-btn" onClick={() => setAddTarget(null)} aria-label="Close side panel">
862
+ <X size={14} />
863
+ </button>
864
+ <span>Select Action</span>
865
+ <em>Workflow step</em>
866
+ </div>
867
+ <WorkflowAddStepPanel
868
+ target={addTarget}
869
+ onSelect={insertActionNode}
870
+ />
871
+ </div>
872
+ )}
873
+ {graphUiState === "populated" && !addTarget && selectedNode && (
874
+ <div className="dm-orchestration-sidecar__config-col">
875
+ <div className="dm-workflow-panel-head">
876
+ <button type="button" className="dm-workflow-icon-btn" onClick={() => setSelectedNodeId("")} aria-label="Close side panel">
877
+ <X size={14} />
878
+ </button>
879
+ <span>{selectedNode?.label || selectedNode?.id}</span>
880
+ <em>{selectedNode?.type}</em>
881
+ </div>
882
+ <OrchestrationNodeConfigPanel
883
+ node={selectedNode}
884
+ registryRow={registryRow}
885
+ workspaceConfig={workspaceConfig}
886
+ sandboxRow={sandboxRow}
887
+ onDeleteNode={deleteSelectedNode}
888
+ disabled={false}
889
+ activeTab={configTab}
890
+ onTabChange={setConfigTab}
891
+ onConfigChange={handleNodeConfigChange}
892
+ />
893
+ {graphError && <p className="dm-orchestration-config__error">{graphError}</p>}
894
+ </div>
895
+ )}
896
+ </div>
897
+ </div>
898
+ )}
899
+
900
+ {(saveMessage || runMessage) && (
901
+ <p className="dm-workflow-status-msg">{saveMessage || runMessage}</p>
902
+ )}
903
+ </section>
904
+ </main>
905
+ );
906
+ }