@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +50 -25
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +38 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +522 -35
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +242 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +52 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +1203 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +163 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +190 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +64 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolDraftPanel.jsx +376 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +6 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1062 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +10 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +906 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/page.jsx +12 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +492 -28
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +114 -30
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/nav-workflows.js +54 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +322 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +734 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +73 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-sidecar-routing.js +24 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +2 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +21 -1
- 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
|
+
}
|