Skip to content

Commit 36fd367

Browse files
committed
fix(server): export AI node implementations and sanitize credential env var names
1 parent 5b801d2 commit 36fd367

File tree

5 files changed

+320
-4
lines changed

5 files changed

+320
-4
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
desktop: patch
3+
cli: patch
4+
---
5+
6+
Fix AI node export and credential env var name sanitization

packages/server/pkg/ioworkspace/exporter.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap"
1111
"github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow"
12+
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/scredential"
1213
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv"
1314
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sfile"
1415
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow"
@@ -68,6 +69,11 @@ func (s *IOWorkspaceService) Export(ctx context.Context, opts ExportOptions) (*W
6869
}
6970
}
7071

72+
// Export credentials
73+
if err := s.exportCredentials(ctx, opts, bundle); err != nil {
74+
return nil, fmt.Errorf("failed to export credentials: %w", err)
75+
}
76+
7177
counts := bundle.CountEntities()
7278
s.logger.InfoContext(ctx, "Workspace export completed", "counts", counts)
7379

@@ -213,6 +219,9 @@ func (s *IOWorkspaceService) exportFlows(ctx context.Context, opts ExportOptions
213219
nodeForService := sflow.NewNodeForService(s.queries)
214220
nodeForEachService := sflow.NewNodeForEachService(s.queries)
215221
nodeJSService := sflow.NewNodeJsService(s.queries)
222+
nodeAIService := sflow.NewNodeAIService(s.queries)
223+
nodeAIProviderService := sflow.NewNodeAiProviderService(s.queries)
224+
nodeMemoryService := sflow.NewNodeMemoryService(s.queries)
216225

217226
var flowIDs []idwrap.IDWrap
218227

@@ -266,7 +275,7 @@ func (s *IOWorkspaceService) exportFlows(ctx context.Context, opts ExportOptions
266275

267276
// Export node implementations based on node types
268277
for _, node := range nodes {
269-
if err := s.exportNodeImplementation(ctx, node, bundle, nodeRequestService, nodeIfService, nodeForService, nodeForEachService, nodeJSService); err != nil {
278+
if err := s.exportNodeImplementation(ctx, node, bundle, nodeRequestService, nodeIfService, nodeForService, nodeForEachService, nodeJSService, nodeAIService, nodeAIProviderService, nodeMemoryService); err != nil {
270279
return fmt.Errorf("failed to export node implementation for node %s: %w", node.ID.String(), err)
271280
}
272281
}
@@ -280,7 +289,10 @@ func (s *IOWorkspaceService) exportFlows(ctx context.Context, opts ExportOptions
280289
"condition_nodes", len(bundle.FlowConditionNodes),
281290
"for_nodes", len(bundle.FlowForNodes),
282291
"foreach_nodes", len(bundle.FlowForEachNodes),
283-
"js_nodes", len(bundle.FlowJSNodes))
292+
"js_nodes", len(bundle.FlowJSNodes),
293+
"ai_nodes", len(bundle.FlowAINodes),
294+
"ai_provider_nodes", len(bundle.FlowAIProviderNodes),
295+
"ai_memory_nodes", len(bundle.FlowAIMemoryNodes))
284296

285297
return nil
286298
}
@@ -295,6 +307,9 @@ func (s *IOWorkspaceService) exportNodeImplementation(
295307
nodeForService sflow.NodeForService,
296308
nodeForEachService sflow.NodeForEachService,
297309
nodeJSService sflow.NodeJsService,
310+
nodeAIService sflow.NodeAIService,
311+
nodeAIProviderService sflow.NodeAiProviderService,
312+
nodeMemoryService sflow.NodeMemoryService,
298313
) error {
299314
switch node.NodeKind {
300315
case mflow.NODE_KIND_REQUEST:
@@ -341,8 +356,48 @@ func (s *IOWorkspaceService) exportNodeImplementation(
341356
if nodeJS != nil {
342357
bundle.FlowJSNodes = append(bundle.FlowJSNodes, *nodeJS)
343358
}
359+
360+
case mflow.NODE_KIND_AI:
361+
nodeAI, err := nodeAIService.GetNodeAI(ctx, node.ID)
362+
if err != nil {
363+
return fmt.Errorf("failed to get AI node: %w", err)
364+
}
365+
if nodeAI != nil {
366+
bundle.FlowAINodes = append(bundle.FlowAINodes, *nodeAI)
367+
}
368+
369+
case mflow.NODE_KIND_AI_PROVIDER:
370+
nodeAIProvider, err := nodeAIProviderService.GetNodeAiProvider(ctx, node.ID)
371+
if err != nil {
372+
return fmt.Errorf("failed to get AI provider node: %w", err)
373+
}
374+
if nodeAIProvider != nil {
375+
bundle.FlowAIProviderNodes = append(bundle.FlowAIProviderNodes, *nodeAIProvider)
376+
}
377+
378+
case mflow.NODE_KIND_AI_MEMORY:
379+
nodeMemory, err := nodeMemoryService.GetNodeMemory(ctx, node.ID)
380+
if err != nil {
381+
return fmt.Errorf("failed to get AI memory node: %w", err)
382+
}
383+
if nodeMemory != nil {
384+
bundle.FlowAIMemoryNodes = append(bundle.FlowAIMemoryNodes, *nodeMemory)
385+
}
386+
}
387+
388+
return nil
389+
}
390+
391+
// exportCredentials exports workspace credentials (metadata only, no secrets).
392+
func (s *IOWorkspaceService) exportCredentials(ctx context.Context, opts ExportOptions, bundle *WorkspaceBundle) error {
393+
credentialReader := scredential.NewCredentialReaderFromQueries(s.queries)
394+
creds, err := credentialReader.ListCredentials(ctx, opts.WorkspaceID)
395+
if err != nil {
396+
return fmt.Errorf("failed to list credentials: %w", err)
344397
}
398+
bundle.Credentials = creds
345399

400+
s.logger.DebugContext(ctx, "Exported credentials", "count", len(bundle.Credentials))
346401
return nil
347402
}
348403

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package ioworkspace
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen"
8+
"github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlitemem"
9+
"github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap"
10+
"github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mcredential"
11+
"github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow"
12+
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/scredential"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestExport_AINodes(t *testing.T) {
19+
ctx := context.Background()
20+
21+
db, _, err := sqlitemem.NewSQLiteMem(ctx)
22+
require.NoError(t, err)
23+
24+
queries := gen.New(db)
25+
wsID := idwrap.NewNow()
26+
27+
err = queries.CreateWorkspace(ctx, gen.CreateWorkspaceParams{
28+
ID: wsID,
29+
Name: "Test WS",
30+
Updated: 0,
31+
})
32+
require.NoError(t, err)
33+
34+
// Create credential for AI provider node
35+
credID := idwrap.NewNow()
36+
credWriter := scredential.NewCredentialWriterFromQueries(queries)
37+
err = credWriter.CreateCredential(ctx, &mcredential.Credential{
38+
ID: credID,
39+
WorkspaceID: wsID,
40+
Name: "OpenAI Key",
41+
Kind: mcredential.CREDENTIAL_KIND_OPENAI,
42+
})
43+
require.NoError(t, err)
44+
45+
// Build bundle with AI nodes
46+
flowID := idwrap.NewNow()
47+
aiNodeID := idwrap.NewNow()
48+
aiProviderNodeID := idwrap.NewNow()
49+
aiMemoryNodeID := idwrap.NewNow()
50+
51+
temp := float32(0.7)
52+
53+
bundle := &WorkspaceBundle{
54+
Flows: []mflow.Flow{
55+
{ID: flowID, WorkspaceID: wsID, Name: "AI Flow"},
56+
},
57+
FlowNodes: []mflow.Node{
58+
{ID: aiNodeID, FlowID: flowID, NodeKind: mflow.NODE_KIND_AI, Name: "AI Agent"},
59+
{ID: aiProviderNodeID, FlowID: flowID, NodeKind: mflow.NODE_KIND_AI_PROVIDER, Name: "AI Provider"},
60+
{ID: aiMemoryNodeID, FlowID: flowID, NodeKind: mflow.NODE_KIND_AI_MEMORY, Name: "AI Memory"},
61+
},
62+
FlowAINodes: []mflow.NodeAI{
63+
{FlowNodeID: aiNodeID, Prompt: "You are a helpful assistant", MaxIterations: 5},
64+
},
65+
FlowAIProviderNodes: []mflow.NodeAiProvider{
66+
{FlowNodeID: aiProviderNodeID, CredentialID: &credID, Model: mflow.AiModelGpt52, Temperature: &temp},
67+
},
68+
FlowAIMemoryNodes: []mflow.NodeMemory{
69+
{FlowNodeID: aiMemoryNodeID, MemoryType: mflow.AiMemoryTypeWindowBuffer, WindowSize: 10},
70+
},
71+
}
72+
73+
// Import into DB
74+
svc := New(queries, nil)
75+
76+
tx, err := db.BeginTx(ctx, nil)
77+
require.NoError(t, err)
78+
79+
_, err = svc.Import(ctx, tx, bundle, ImportOptions{
80+
WorkspaceID: wsID,
81+
PreserveIDs: true,
82+
ImportFlows: true,
83+
})
84+
require.NoError(t, err)
85+
require.NoError(t, tx.Commit())
86+
87+
// Export and verify AI node data is present
88+
exported, err := svc.Export(ctx, ExportOptions{
89+
WorkspaceID: wsID,
90+
IncludeFlows: true,
91+
ExportFormat: "json",
92+
})
93+
require.NoError(t, err)
94+
95+
// Verify AI nodes
96+
assert.Len(t, exported.FlowAINodes, 1)
97+
assert.Equal(t, "You are a helpful assistant", exported.FlowAINodes[0].Prompt)
98+
assert.Equal(t, int32(5), exported.FlowAINodes[0].MaxIterations)
99+
100+
// Verify AI provider nodes
101+
assert.Len(t, exported.FlowAIProviderNodes, 1)
102+
assert.Equal(t, mflow.AiModelGpt52, exported.FlowAIProviderNodes[0].Model)
103+
require.NotNil(t, exported.FlowAIProviderNodes[0].Temperature)
104+
assert.InDelta(t, 0.7, float64(*exported.FlowAIProviderNodes[0].Temperature), 0.001)
105+
require.NotNil(t, exported.FlowAIProviderNodes[0].CredentialID)
106+
assert.Equal(t, credID, *exported.FlowAIProviderNodes[0].CredentialID)
107+
108+
// Verify AI memory nodes
109+
assert.Len(t, exported.FlowAIMemoryNodes, 1)
110+
assert.Equal(t, mflow.AiMemoryTypeWindowBuffer, exported.FlowAIMemoryNodes[0].MemoryType)
111+
assert.Equal(t, int32(10), exported.FlowAIMemoryNodes[0].WindowSize)
112+
113+
// Verify credentials exported
114+
assert.Len(t, exported.Credentials, 1)
115+
assert.Equal(t, "OpenAI Key", exported.Credentials[0].Name)
116+
assert.Equal(t, mcredential.CREDENTIAL_KIND_OPENAI, exported.Credentials[0].Kind)
117+
}
118+
119+
func TestExportImport_AINodes_RoundTrip(t *testing.T) {
120+
ctx := context.Background()
121+
122+
db, _, err := sqlitemem.NewSQLiteMem(ctx)
123+
require.NoError(t, err)
124+
125+
queries := gen.New(db)
126+
wsID := idwrap.NewNow()
127+
128+
err = queries.CreateWorkspace(ctx, gen.CreateWorkspaceParams{
129+
ID: wsID,
130+
Name: "Test WS",
131+
Updated: 0,
132+
})
133+
require.NoError(t, err)
134+
135+
// Create credential
136+
credID := idwrap.NewNow()
137+
credWriter := scredential.NewCredentialWriterFromQueries(queries)
138+
err = credWriter.CreateCredential(ctx, &mcredential.Credential{
139+
ID: credID,
140+
WorkspaceID: wsID,
141+
Name: "Anthropic Key",
142+
Kind: mcredential.CREDENTIAL_KIND_ANTHROPIC,
143+
})
144+
require.NoError(t, err)
145+
146+
// Build original bundle
147+
flowID := idwrap.NewNow()
148+
aiNodeID := idwrap.NewNow()
149+
aiProviderNodeID := idwrap.NewNow()
150+
aiMemoryNodeID := idwrap.NewNow()
151+
152+
temp := float32(0.9)
153+
maxTokens := int32(4096)
154+
155+
originalBundle := &WorkspaceBundle{
156+
Flows: []mflow.Flow{
157+
{ID: flowID, WorkspaceID: wsID, Name: "AI Round Trip Flow"},
158+
},
159+
FlowNodes: []mflow.Node{
160+
{ID: aiNodeID, FlowID: flowID, NodeKind: mflow.NODE_KIND_AI, Name: "Agent"},
161+
{ID: aiProviderNodeID, FlowID: flowID, NodeKind: mflow.NODE_KIND_AI_PROVIDER, Name: "Provider"},
162+
{ID: aiMemoryNodeID, FlowID: flowID, NodeKind: mflow.NODE_KIND_AI_MEMORY, Name: "Memory"},
163+
},
164+
FlowAINodes: []mflow.NodeAI{
165+
{FlowNodeID: aiNodeID, Prompt: "Analyze the data", MaxIterations: 3},
166+
},
167+
FlowAIProviderNodes: []mflow.NodeAiProvider{
168+
{FlowNodeID: aiProviderNodeID, CredentialID: &credID, Model: mflow.AiModelClaudeSonnet45, Temperature: &temp, MaxTokens: &maxTokens},
169+
},
170+
FlowAIMemoryNodes: []mflow.NodeMemory{
171+
{FlowNodeID: aiMemoryNodeID, MemoryType: mflow.AiMemoryTypeWindowBuffer, WindowSize: 20},
172+
},
173+
}
174+
175+
svc := New(queries, nil)
176+
177+
// Import original bundle
178+
tx, err := db.BeginTx(ctx, nil)
179+
require.NoError(t, err)
180+
181+
_, err = svc.Import(ctx, tx, originalBundle, ImportOptions{
182+
WorkspaceID: wsID,
183+
PreserveIDs: true,
184+
ImportFlows: true,
185+
})
186+
require.NoError(t, err)
187+
require.NoError(t, tx.Commit())
188+
189+
// Export from DB
190+
exported, err := svc.Export(ctx, ExportOptions{
191+
WorkspaceID: wsID,
192+
IncludeFlows: true,
193+
ExportFormat: "json",
194+
})
195+
require.NoError(t, err)
196+
197+
// Re-import into a fresh workspace
198+
wsID2 := idwrap.NewNow()
199+
err = queries.CreateWorkspace(ctx, gen.CreateWorkspaceParams{
200+
ID: wsID2,
201+
Name: "Test WS 2",
202+
Updated: 0,
203+
})
204+
require.NoError(t, err)
205+
206+
tx2, err := db.BeginTx(ctx, nil)
207+
require.NoError(t, err)
208+
209+
result2, err := svc.Import(ctx, tx2, exported, ImportOptions{
210+
WorkspaceID: wsID2,
211+
PreserveIDs: false,
212+
ImportFlows: true,
213+
})
214+
require.NoError(t, err)
215+
require.NoError(t, tx2.Commit())
216+
217+
assert.Equal(t, 1, result2.FlowAINodesCreated)
218+
assert.Equal(t, 1, result2.FlowAIProviderNodesCreated)
219+
assert.Equal(t, 1, result2.FlowAIMemoryNodesCreated)
220+
221+
// Export again and verify data survived the round-trip
222+
reExported, err := svc.Export(ctx, ExportOptions{
223+
WorkspaceID: wsID2,
224+
IncludeFlows: true,
225+
ExportFormat: "json",
226+
})
227+
require.NoError(t, err)
228+
229+
// Verify AI node data
230+
require.Len(t, reExported.FlowAINodes, 1)
231+
assert.Equal(t, "Analyze the data", reExported.FlowAINodes[0].Prompt)
232+
assert.Equal(t, int32(3), reExported.FlowAINodes[0].MaxIterations)
233+
234+
// Verify AI provider node data
235+
require.Len(t, reExported.FlowAIProviderNodes, 1)
236+
assert.Equal(t, mflow.AiModelClaudeSonnet45, reExported.FlowAIProviderNodes[0].Model)
237+
require.NotNil(t, reExported.FlowAIProviderNodes[0].Temperature)
238+
assert.InDelta(t, 0.9, float64(*reExported.FlowAIProviderNodes[0].Temperature), 0.001)
239+
require.NotNil(t, reExported.FlowAIProviderNodes[0].MaxTokens)
240+
assert.Equal(t, int32(4096), *reExported.FlowAIProviderNodes[0].MaxTokens)
241+
242+
// Verify AI memory node data
243+
require.Len(t, reExported.FlowAIMemoryNodes, 1)
244+
assert.Equal(t, mflow.AiMemoryTypeWindowBuffer, reExported.FlowAIMemoryNodes[0].MemoryType)
245+
assert.Equal(t, int32(20), reExported.FlowAIMemoryNodes[0].WindowSize)
246+
}

packages/server/pkg/translate/yamlflowsimplev2/exporter.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package yamlflowsimplev2
44
import (
55
"fmt"
66
"sort"
7-
"strings"
87

98
"github.com/the-dev-tools/dev-tools/packages/server/pkg/flowgraph"
109
"github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap"
@@ -468,7 +467,7 @@ func MarshalSimplifiedYAML(data *ioworkspace.WorkspaceBundle) ([]byte, error) {
468467

469468
// 4. Export credentials from bundle (metadata only, secrets use env placeholders)
470469
for _, cred := range data.Credentials {
471-
envVarName := strings.ToUpper(strings.ReplaceAll(cred.Name, "-", "_"))
470+
envVarName := credentialNameToEnvVar(cred.Name)
472471

473472
yamlCred := YamlCredentialV2{Name: cred.Name}
474473

packages/server/pkg/translate/yamlflowsimplev2/utils.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ const (
4848
EnvVarTemplateAPIKey = "{{ #env:%s_API_KEY }}" //nolint:gosec // G101: template pattern, not a credential
4949
)
5050

51+
// envVarInvalidChars matches any character that is not a letter, digit, or underscore.
52+
var envVarInvalidChars = regexp.MustCompile(`[^A-Za-z0-9_]`)
53+
54+
// credentialNameToEnvVar converts a credential name to a valid environment variable
55+
// name by replacing all non-alphanumeric characters (spaces, hyphens, dots, etc.)
56+
// with underscores and uppercasing the result.
57+
func credentialNameToEnvVar(name string) string {
58+
return strings.ToUpper(envVarInvalidChars.ReplaceAllString(name, "_"))
59+
}
60+
5161
// Helper functions for additional utilities and transformations
5262

5363
// ValidateURL validates and normalizes a URL

0 commit comments

Comments
 (0)