kailash 0.1.5__py3-none-any.whl → 0.2.0__py3-none-any.whl

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 (75) hide show
  1. kailash/__init__.py +1 -1
  2. kailash/access_control.py +740 -0
  3. kailash/api/__main__.py +6 -0
  4. kailash/api/auth.py +668 -0
  5. kailash/api/custom_nodes.py +285 -0
  6. kailash/api/custom_nodes_secure.py +377 -0
  7. kailash/api/database.py +620 -0
  8. kailash/api/studio.py +915 -0
  9. kailash/api/studio_secure.py +893 -0
  10. kailash/mcp/__init__.py +53 -0
  11. kailash/mcp/__main__.py +13 -0
  12. kailash/mcp/ai_registry_server.py +712 -0
  13. kailash/mcp/client.py +447 -0
  14. kailash/mcp/client_new.py +334 -0
  15. kailash/mcp/server.py +293 -0
  16. kailash/mcp/server_new.py +336 -0
  17. kailash/mcp/servers/__init__.py +12 -0
  18. kailash/mcp/servers/ai_registry.py +289 -0
  19. kailash/nodes/__init__.py +4 -2
  20. kailash/nodes/ai/__init__.py +2 -0
  21. kailash/nodes/ai/a2a.py +714 -67
  22. kailash/nodes/ai/intelligent_agent_orchestrator.py +31 -37
  23. kailash/nodes/ai/iterative_llm_agent.py +1280 -0
  24. kailash/nodes/ai/llm_agent.py +324 -1
  25. kailash/nodes/ai/self_organizing.py +5 -6
  26. kailash/nodes/base.py +15 -2
  27. kailash/nodes/base_async.py +45 -0
  28. kailash/nodes/base_cycle_aware.py +374 -0
  29. kailash/nodes/base_with_acl.py +338 -0
  30. kailash/nodes/code/python.py +135 -27
  31. kailash/nodes/data/readers.py +16 -6
  32. kailash/nodes/data/writers.py +16 -6
  33. kailash/nodes/logic/__init__.py +8 -0
  34. kailash/nodes/logic/convergence.py +642 -0
  35. kailash/nodes/logic/loop.py +153 -0
  36. kailash/nodes/logic/operations.py +187 -27
  37. kailash/nodes/mixins/__init__.py +11 -0
  38. kailash/nodes/mixins/mcp.py +228 -0
  39. kailash/nodes/mixins.py +387 -0
  40. kailash/runtime/__init__.py +2 -1
  41. kailash/runtime/access_controlled.py +458 -0
  42. kailash/runtime/local.py +106 -33
  43. kailash/runtime/parallel_cyclic.py +529 -0
  44. kailash/sdk_exceptions.py +90 -5
  45. kailash/security.py +845 -0
  46. kailash/tracking/manager.py +38 -15
  47. kailash/tracking/models.py +1 -1
  48. kailash/tracking/storage/filesystem.py +30 -2
  49. kailash/utils/__init__.py +8 -0
  50. kailash/workflow/__init__.py +18 -0
  51. kailash/workflow/convergence.py +270 -0
  52. kailash/workflow/cycle_analyzer.py +768 -0
  53. kailash/workflow/cycle_builder.py +573 -0
  54. kailash/workflow/cycle_config.py +709 -0
  55. kailash/workflow/cycle_debugger.py +760 -0
  56. kailash/workflow/cycle_exceptions.py +601 -0
  57. kailash/workflow/cycle_profiler.py +671 -0
  58. kailash/workflow/cycle_state.py +338 -0
  59. kailash/workflow/cyclic_runner.py +985 -0
  60. kailash/workflow/graph.py +500 -39
  61. kailash/workflow/migration.py +768 -0
  62. kailash/workflow/safety.py +365 -0
  63. kailash/workflow/templates.py +744 -0
  64. kailash/workflow/validation.py +693 -0
  65. {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/METADATA +256 -12
  66. kailash-0.2.0.dist-info/RECORD +125 -0
  67. kailash/nodes/mcp/__init__.py +0 -11
  68. kailash/nodes/mcp/client.py +0 -554
  69. kailash/nodes/mcp/resource.py +0 -682
  70. kailash/nodes/mcp/server.py +0 -577
  71. kailash-0.1.5.dist-info/RECORD +0 -88
  72. {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/WHEEL +0 -0
  73. {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/entry_points.txt +0 -0
  74. {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/licenses/LICENSE +0 -0
  75. {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,285 @@
1
+ """
2
+ Custom Node API endpoints for Kailash Workflow Studio.
3
+
4
+ This module provides endpoints for users to:
5
+ - Create custom nodes with visual configuration
6
+ - Implement nodes using Python code, workflows, or API calls
7
+ - Manage and version custom nodes
8
+ - Share nodes within a tenant
9
+ """
10
+
11
+ from datetime import datetime
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class CustomNodeCreate(BaseModel):
18
+ """Request model for creating a custom node"""
19
+
20
+ name: str = Field(..., min_length=1, max_length=255)
21
+ category: str = Field(default="custom", max_length=100)
22
+ description: Optional[str] = None
23
+ icon: Optional[str] = Field(None, max_length=50)
24
+ color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
25
+
26
+ # Node configuration
27
+ parameters: List[Dict[str, Any]] = Field(default_factory=list)
28
+ inputs: List[Dict[str, Any]] = Field(default_factory=list)
29
+ outputs: List[Dict[str, Any]] = Field(default_factory=list)
30
+
31
+ # Implementation
32
+ implementation_type: str = Field(..., pattern="^(python|workflow|api)$")
33
+ implementation: Dict[str, Any]
34
+
35
+
36
+ class CustomNodeUpdate(BaseModel):
37
+ """Request model for updating a custom node"""
38
+
39
+ name: Optional[str] = Field(None, min_length=1, max_length=255)
40
+ category: Optional[str] = Field(None, max_length=100)
41
+ description: Optional[str] = None
42
+ icon: Optional[str] = Field(None, max_length=50)
43
+ color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
44
+
45
+ # Node configuration
46
+ parameters: Optional[List[Dict[str, Any]]] = None
47
+ inputs: Optional[List[Dict[str, Any]]] = None
48
+ outputs: Optional[List[Dict[str, Any]]] = None
49
+
50
+ # Implementation
51
+ implementation_type: Optional[str] = Field(None, pattern="^(python|workflow|api)$")
52
+ implementation: Optional[Dict[str, Any]] = None
53
+
54
+ # Publishing
55
+ is_published: Optional[bool] = None
56
+
57
+
58
+ class CustomNodeResponse(BaseModel):
59
+ """Response model for custom node"""
60
+
61
+ id: str
62
+ tenant_id: str
63
+ name: str
64
+ category: str
65
+ description: Optional[str]
66
+ icon: Optional[str]
67
+ color: Optional[str]
68
+
69
+ # Node configuration
70
+ parameters: List[Dict[str, Any]]
71
+ inputs: List[Dict[str, Any]]
72
+ outputs: List[Dict[str, Any]]
73
+
74
+ # Implementation
75
+ implementation_type: str
76
+ implementation: Dict[str, Any]
77
+
78
+ # Metadata
79
+ is_published: bool
80
+ created_by: Optional[str]
81
+ created_at: datetime
82
+ updated_at: datetime
83
+
84
+
85
+ def setup_custom_node_routes(app, SessionLocal, tenant_id: str):
86
+ """Setup custom node API routes"""
87
+ from fastapi import HTTPException
88
+
89
+ from .database import CustomNodeRepository, get_db_session
90
+
91
+ @app.get("/api/custom-nodes", response_model=List[CustomNodeResponse])
92
+ async def list_custom_nodes():
93
+ """List all custom nodes for the tenant"""
94
+ with get_db_session(SessionLocal) as session:
95
+ repo = CustomNodeRepository(session)
96
+ nodes = repo.list(tenant_id)
97
+
98
+ return [
99
+ CustomNodeResponse(
100
+ id=node.id,
101
+ tenant_id=node.tenant_id,
102
+ name=node.name,
103
+ category=node.category,
104
+ description=node.description,
105
+ icon=node.icon,
106
+ color=node.color,
107
+ parameters=node.parameters or [],
108
+ inputs=node.inputs or [],
109
+ outputs=node.outputs or [],
110
+ implementation_type=node.implementation_type,
111
+ implementation=node.implementation or {},
112
+ is_published=node.is_published,
113
+ created_by=node.created_by,
114
+ created_at=node.created_at,
115
+ updated_at=node.updated_at,
116
+ )
117
+ for node in nodes
118
+ ]
119
+
120
+ @app.post("/api/custom-nodes", response_model=CustomNodeResponse)
121
+ async def create_custom_node(request: CustomNodeCreate):
122
+ """Create a new custom node"""
123
+ with get_db_session(SessionLocal) as session:
124
+ repo = CustomNodeRepository(session)
125
+
126
+ # Check if node name already exists
127
+ existing_nodes = repo.list(tenant_id)
128
+ if any(node.name == request.name for node in existing_nodes):
129
+ raise HTTPException(
130
+ status_code=400,
131
+ detail=f"Custom node with name '{request.name}' already exists",
132
+ )
133
+
134
+ # Create node
135
+ node_data = request.dict()
136
+ node = repo.create(tenant_id, node_data)
137
+
138
+ return CustomNodeResponse(
139
+ id=node.id,
140
+ tenant_id=node.tenant_id,
141
+ name=node.name,
142
+ category=node.category,
143
+ description=node.description,
144
+ icon=node.icon,
145
+ color=node.color,
146
+ parameters=node.parameters or [],
147
+ inputs=node.inputs or [],
148
+ outputs=node.outputs or [],
149
+ implementation_type=node.implementation_type,
150
+ implementation=node.implementation or {},
151
+ is_published=node.is_published,
152
+ created_by=node.created_by,
153
+ created_at=node.created_at,
154
+ updated_at=node.updated_at,
155
+ )
156
+
157
+ @app.get("/api/custom-nodes/{node_id}", response_model=CustomNodeResponse)
158
+ async def get_custom_node(node_id: str):
159
+ """Get a specific custom node"""
160
+ with get_db_session(SessionLocal) as session:
161
+ repo = CustomNodeRepository(session)
162
+ node = repo.get(node_id)
163
+
164
+ if not node or node.tenant_id != tenant_id:
165
+ raise HTTPException(status_code=404, detail="Custom node not found")
166
+
167
+ return CustomNodeResponse(
168
+ id=node.id,
169
+ tenant_id=node.tenant_id,
170
+ name=node.name,
171
+ category=node.category,
172
+ description=node.description,
173
+ icon=node.icon,
174
+ color=node.color,
175
+ parameters=node.parameters or [],
176
+ inputs=node.inputs or [],
177
+ outputs=node.outputs or [],
178
+ implementation_type=node.implementation_type,
179
+ implementation=node.implementation or {},
180
+ is_published=node.is_published,
181
+ created_by=node.created_by,
182
+ created_at=node.created_at,
183
+ updated_at=node.updated_at,
184
+ )
185
+
186
+ @app.put("/api/custom-nodes/{node_id}", response_model=CustomNodeResponse)
187
+ async def update_custom_node(node_id: str, request: CustomNodeUpdate):
188
+ """Update a custom node"""
189
+ with get_db_session(SessionLocal) as session:
190
+ repo = CustomNodeRepository(session)
191
+ node = repo.get(node_id)
192
+
193
+ if not node or node.tenant_id != tenant_id:
194
+ raise HTTPException(status_code=404, detail="Custom node not found")
195
+
196
+ # Update node
197
+ updates = request.dict(exclude_unset=True)
198
+ node = repo.update(node_id, updates)
199
+
200
+ return CustomNodeResponse(
201
+ id=node.id,
202
+ tenant_id=node.tenant_id,
203
+ name=node.name,
204
+ category=node.category,
205
+ description=node.description,
206
+ icon=node.icon,
207
+ color=node.color,
208
+ parameters=node.parameters or [],
209
+ inputs=node.inputs or [],
210
+ outputs=node.outputs or [],
211
+ implementation_type=node.implementation_type,
212
+ implementation=node.implementation or {},
213
+ is_published=node.is_published,
214
+ created_by=node.created_by,
215
+ created_at=node.created_at,
216
+ updated_at=node.updated_at,
217
+ )
218
+
219
+ @app.delete("/api/custom-nodes/{node_id}")
220
+ async def delete_custom_node(node_id: str):
221
+ """Delete a custom node"""
222
+ with get_db_session(SessionLocal) as session:
223
+ repo = CustomNodeRepository(session)
224
+ node = repo.get(node_id)
225
+
226
+ if not node or node.tenant_id != tenant_id:
227
+ raise HTTPException(status_code=404, detail="Custom node not found")
228
+
229
+ repo.delete(node_id)
230
+ return {"message": "Custom node deleted successfully"}
231
+
232
+ @app.post("/api/custom-nodes/{node_id}/test")
233
+ async def test_custom_node(node_id: str, test_data: Dict[str, Any]):
234
+ """Test a custom node with sample data"""
235
+ with get_db_session(SessionLocal) as session:
236
+ repo = CustomNodeRepository(session)
237
+ node = repo.get(node_id)
238
+
239
+ if not node or node.tenant_id != tenant_id:
240
+ raise HTTPException(status_code=404, detail="Custom node not found")
241
+
242
+ # Execute node based on implementation type
243
+ try:
244
+ if node.implementation_type == "python":
245
+ # Execute Python code
246
+ result = _execute_python_node(node, test_data)
247
+ elif node.implementation_type == "workflow":
248
+ # Execute workflow
249
+ result = _execute_workflow_node(node, test_data)
250
+ elif node.implementation_type == "api":
251
+ # Execute API call
252
+ result = _execute_api_node(node, test_data)
253
+ else:
254
+ raise ValueError(
255
+ f"Unknown implementation type: {node.implementation_type}"
256
+ )
257
+
258
+ return {
259
+ "success": True,
260
+ "result": result,
261
+ "execution_time_ms": 0, # TODO: Track actual execution time
262
+ }
263
+ except Exception as e:
264
+ return {"success": False, "error": str(e), "execution_time_ms": 0}
265
+
266
+
267
+ def _execute_python_node(node, test_data):
268
+ """Execute a Python-based custom node"""
269
+ # This would execute the Python code in a sandboxed environment
270
+ # For now, return mock result
271
+ return {"output": f"Executed {node.name} with Python implementation"}
272
+
273
+
274
+ def _execute_workflow_node(node, test_data):
275
+ """Execute a workflow-based custom node"""
276
+ # This would create and execute a workflow from the stored definition
277
+ # For now, return mock result
278
+ return {"output": f"Executed {node.name} with Workflow implementation"}
279
+
280
+
281
+ def _execute_api_node(node, test_data):
282
+ """Execute an API-based custom node"""
283
+ # This would make HTTP requests based on the API configuration
284
+ # For now, return mock result
285
+ return {"output": f"Executed {node.name} with API implementation"}
@@ -0,0 +1,377 @@
1
+ """
2
+ Custom Node API endpoints for Kailash Workflow Studio with authentication.
3
+
4
+ This module provides secure endpoints for users to:
5
+ - Create custom nodes with visual configuration
6
+ - Implement nodes using Python code, workflows, or API calls
7
+ - Manage and version custom nodes
8
+ - Share nodes within a tenant
9
+ """
10
+
11
+ from datetime import datetime
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from fastapi import Depends, HTTPException
15
+ from pydantic import BaseModel, Field
16
+ from sqlalchemy.orm import Session
17
+
18
+ from .auth import Tenant, User, get_current_tenant, require_permission
19
+ from .database import CustomNode, CustomNodeRepository, get_db_session
20
+
21
+
22
+ class CustomNodeCreate(BaseModel):
23
+ """Request model for creating a custom node"""
24
+
25
+ name: str = Field(..., min_length=1, max_length=255)
26
+ category: str = Field(default="custom", max_length=100)
27
+ description: Optional[str] = None
28
+ icon: Optional[str] = Field(None, max_length=50)
29
+ color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
30
+
31
+ # Node configuration
32
+ parameters: List[Dict[str, Any]] = Field(default_factory=list)
33
+ inputs: List[Dict[str, Any]] = Field(default_factory=list)
34
+ outputs: List[Dict[str, Any]] = Field(default_factory=list)
35
+
36
+ # Implementation
37
+ implementation_type: str = Field(..., pattern="^(python|workflow|api)$")
38
+ implementation: Dict[str, Any]
39
+
40
+
41
+ class CustomNodeUpdate(BaseModel):
42
+ """Request model for updating a custom node"""
43
+
44
+ name: Optional[str] = Field(None, min_length=1, max_length=255)
45
+ category: Optional[str] = Field(None, max_length=100)
46
+ description: Optional[str] = None
47
+ icon: Optional[str] = Field(None, max_length=50)
48
+ color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
49
+
50
+ # Node configuration
51
+ parameters: Optional[List[Dict[str, Any]]] = None
52
+ inputs: Optional[List[Dict[str, Any]]] = None
53
+ outputs: Optional[List[Dict[str, Any]]] = None
54
+
55
+ # Implementation
56
+ implementation_type: Optional[str] = Field(None, pattern="^(python|workflow|api)$")
57
+ implementation: Optional[Dict[str, Any]] = None
58
+
59
+ # Publishing
60
+ is_published: Optional[bool] = None
61
+
62
+
63
+ class CustomNodeResponse(BaseModel):
64
+ """Response model for custom node"""
65
+
66
+ id: str
67
+ tenant_id: str
68
+ name: str
69
+ category: str
70
+ description: Optional[str]
71
+ icon: Optional[str]
72
+ color: Optional[str]
73
+
74
+ # Node configuration
75
+ parameters: List[Dict[str, Any]]
76
+ inputs: List[Dict[str, Any]]
77
+ outputs: List[Dict[str, Any]]
78
+
79
+ # Implementation
80
+ implementation_type: str
81
+ implementation: Dict[str, Any]
82
+
83
+ # Metadata
84
+ is_published: bool
85
+ created_by: Optional[str]
86
+ created_at: datetime
87
+ updated_at: datetime
88
+
89
+
90
+ def setup_custom_node_routes(app, SessionLocal):
91
+ """Setup custom node API routes with authentication"""
92
+
93
+ @app.get("/api/custom-nodes", response_model=List[CustomNodeResponse])
94
+ async def list_custom_nodes(
95
+ user: User = Depends(require_permission("read:nodes")),
96
+ tenant: Tenant = Depends(get_current_tenant),
97
+ session: Session = Depends(get_db_session),
98
+ ):
99
+ """List all custom nodes for the tenant"""
100
+ repo = CustomNodeRepository(session)
101
+ nodes = repo.list(tenant.id)
102
+
103
+ return [
104
+ CustomNodeResponse(
105
+ id=node.id,
106
+ tenant_id=node.tenant_id,
107
+ name=node.name,
108
+ category=node.category,
109
+ description=node.description,
110
+ icon=node.icon,
111
+ color=node.color,
112
+ parameters=node.parameters or [],
113
+ inputs=node.inputs or [],
114
+ outputs=node.outputs or [],
115
+ implementation_type=node.implementation_type,
116
+ implementation=node.implementation or {},
117
+ is_published=node.is_published,
118
+ created_by=node.created_by,
119
+ created_at=node.created_at,
120
+ updated_at=node.updated_at,
121
+ )
122
+ for node in nodes
123
+ ]
124
+
125
+ @app.post("/api/custom-nodes", response_model=CustomNodeResponse)
126
+ async def create_custom_node(
127
+ request: CustomNodeCreate,
128
+ user: User = Depends(require_permission("write:nodes")),
129
+ tenant: Tenant = Depends(get_current_tenant),
130
+ session: Session = Depends(get_db_session),
131
+ ):
132
+ """Create a new custom node"""
133
+ repo = CustomNodeRepository(session)
134
+
135
+ # Check if node name already exists for this tenant
136
+ existing_nodes = repo.list(tenant.id)
137
+ if any(node.name == request.name for node in existing_nodes):
138
+ raise HTTPException(
139
+ status_code=400,
140
+ detail=f"Custom node with name '{request.name}' already exists",
141
+ )
142
+
143
+ # Create node
144
+ node_data = request.dict()
145
+ node_data["created_by"] = user.email
146
+ node = repo.create(tenant.id, node_data)
147
+
148
+ return CustomNodeResponse(
149
+ id=node.id,
150
+ tenant_id=node.tenant_id,
151
+ name=node.name,
152
+ category=node.category,
153
+ description=node.description,
154
+ icon=node.icon,
155
+ color=node.color,
156
+ parameters=node.parameters or [],
157
+ inputs=node.inputs or [],
158
+ outputs=node.outputs or [],
159
+ implementation_type=node.implementation_type,
160
+ implementation=node.implementation or {},
161
+ is_published=node.is_published,
162
+ created_by=node.created_by,
163
+ created_at=node.created_at,
164
+ updated_at=node.updated_at,
165
+ )
166
+
167
+ @app.get("/api/custom-nodes/{node_id}", response_model=CustomNodeResponse)
168
+ async def get_custom_node(
169
+ node_id: str,
170
+ user: User = Depends(require_permission("read:nodes")),
171
+ tenant: Tenant = Depends(get_current_tenant),
172
+ session: Session = Depends(get_db_session),
173
+ ):
174
+ """Get a specific custom node"""
175
+ repo = CustomNodeRepository(session)
176
+ node = repo.get(node_id)
177
+
178
+ if not node or node.tenant_id != tenant.id:
179
+ raise HTTPException(status_code=404, detail="Custom node not found")
180
+
181
+ return CustomNodeResponse(
182
+ id=node.id,
183
+ tenant_id=node.tenant_id,
184
+ name=node.name,
185
+ category=node.category,
186
+ description=node.description,
187
+ icon=node.icon,
188
+ color=node.color,
189
+ parameters=node.parameters or [],
190
+ inputs=node.inputs or [],
191
+ outputs=node.outputs or [],
192
+ implementation_type=node.implementation_type,
193
+ implementation=node.implementation or {},
194
+ is_published=node.is_published,
195
+ created_by=node.created_by,
196
+ created_at=node.created_at,
197
+ updated_at=node.updated_at,
198
+ )
199
+
200
+ @app.put("/api/custom-nodes/{node_id}", response_model=CustomNodeResponse)
201
+ async def update_custom_node(
202
+ node_id: str,
203
+ request: CustomNodeUpdate,
204
+ user: User = Depends(require_permission("write:nodes")),
205
+ tenant: Tenant = Depends(get_current_tenant),
206
+ session: Session = Depends(get_db_session),
207
+ ):
208
+ """Update a custom node"""
209
+ repo = CustomNodeRepository(session)
210
+ node = repo.get(node_id)
211
+
212
+ if not node or node.tenant_id != tenant.id:
213
+ raise HTTPException(status_code=404, detail="Custom node not found")
214
+
215
+ # Update node
216
+ updates = request.dict(exclude_unset=True)
217
+ node = repo.update(node_id, updates)
218
+
219
+ return CustomNodeResponse(
220
+ id=node.id,
221
+ tenant_id=node.tenant_id,
222
+ name=node.name,
223
+ category=node.category,
224
+ description=node.description,
225
+ icon=node.icon,
226
+ color=node.color,
227
+ parameters=node.parameters or [],
228
+ inputs=node.inputs or [],
229
+ outputs=node.outputs or [],
230
+ implementation_type=node.implementation_type,
231
+ implementation=node.implementation or {},
232
+ is_published=node.is_published,
233
+ created_by=node.created_by,
234
+ created_at=node.created_at,
235
+ updated_at=node.updated_at,
236
+ )
237
+
238
+ @app.delete("/api/custom-nodes/{node_id}")
239
+ async def delete_custom_node(
240
+ node_id: str,
241
+ user: User = Depends(require_permission("delete:nodes")),
242
+ tenant: Tenant = Depends(get_current_tenant),
243
+ session: Session = Depends(get_db_session),
244
+ ):
245
+ """Delete a custom node"""
246
+ repo = CustomNodeRepository(session)
247
+ node = repo.get(node_id)
248
+
249
+ if not node or node.tenant_id != tenant.id:
250
+ raise HTTPException(status_code=404, detail="Custom node not found")
251
+
252
+ repo.delete(node_id)
253
+ return {"message": "Custom node deleted successfully"}
254
+
255
+ @app.post("/api/custom-nodes/{node_id}/test")
256
+ async def test_custom_node(
257
+ node_id: str,
258
+ test_data: Dict[str, Any],
259
+ user: User = Depends(require_permission("execute:nodes")),
260
+ tenant: Tenant = Depends(get_current_tenant),
261
+ session: Session = Depends(get_db_session),
262
+ ):
263
+ """Test a custom node with sample data"""
264
+ repo = CustomNodeRepository(session)
265
+ node = repo.get(node_id)
266
+
267
+ if not node or node.tenant_id != tenant.id:
268
+ raise HTTPException(status_code=404, detail="Custom node not found")
269
+
270
+ # Execute node based on implementation type
271
+ try:
272
+ if node.implementation_type == "python":
273
+ # Execute Python code in sandboxed environment
274
+ result = await _execute_python_node(node, test_data, tenant.id)
275
+ elif node.implementation_type == "workflow":
276
+ # Execute workflow
277
+ result = await _execute_workflow_node(node, test_data, tenant.id)
278
+ elif node.implementation_type == "api":
279
+ # Execute API call
280
+ result = await _execute_api_node(node, test_data, tenant.id)
281
+ else:
282
+ raise ValueError(
283
+ f"Unknown implementation type: {node.implementation_type}"
284
+ )
285
+
286
+ return {
287
+ "success": True,
288
+ "result": result,
289
+ "execution_time_ms": 0, # TODO: Track actual execution time
290
+ }
291
+ except Exception as e:
292
+ return {"success": False, "error": str(e), "execution_time_ms": 0}
293
+
294
+
295
+ async def _execute_python_node(
296
+ node: CustomNode, test_data: Dict[str, Any], tenant_id: str
297
+ ) -> Dict[str, Any]:
298
+ """Execute a Python-based custom node with security sandboxing"""
299
+ from kailash.nodes.code.python import PythonCodeNode
300
+ from kailash.security import SecurityConfig, TenantContext
301
+
302
+ # Create security config for tenant
303
+ security_config = SecurityConfig(
304
+ allowed_directories=[f"tenants/{tenant_id}/sandbox"],
305
+ execution_timeout=30.0, # 30 seconds max
306
+ memory_limit=256 * 1024 * 1024, # 256MB
307
+ )
308
+
309
+ # Execute in tenant context
310
+ with TenantContext(tenant_id):
311
+ # Create Python code node with custom implementation
312
+ python_node = PythonCodeNode(
313
+ code=node.implementation.get("code", ""),
314
+ inputs=node.implementation.get("inputs", []),
315
+ outputs=node.implementation.get("outputs", []),
316
+ security_config=security_config,
317
+ )
318
+
319
+ # Run the node
320
+ result = python_node.run(**test_data)
321
+
322
+ return result
323
+
324
+
325
+ async def _execute_workflow_node(
326
+ node: CustomNode, test_data: Dict[str, Any], tenant_id: str
327
+ ) -> Dict[str, Any]:
328
+ """Execute a workflow-based custom node"""
329
+ from kailash.runtime.local import LocalRuntime
330
+ from kailash.security import TenantContext
331
+ from kailash.workflow import Workflow
332
+
333
+ # Execute in tenant context
334
+ with TenantContext(tenant_id):
335
+ # Create workflow from stored definition
336
+ workflow_def = node.implementation.get("workflow", {})
337
+ workflow = Workflow.from_dict(workflow_def)
338
+
339
+ # Create tenant-isolated runtime
340
+ runtime = LocalRuntime()
341
+
342
+ # Execute workflow
343
+ result, run_id = runtime.execute(workflow, parameters=test_data)
344
+
345
+ return result
346
+
347
+
348
+ async def _execute_api_node(
349
+ node: CustomNode, test_data: Dict[str, Any], tenant_id: str
350
+ ) -> Dict[str, Any]:
351
+ """Execute an API-based custom node"""
352
+
353
+ from kailash.nodes.api.http import HTTPClientNode
354
+ from kailash.security import TenantContext
355
+
356
+ # Execute in tenant context
357
+ with TenantContext(tenant_id):
358
+ # Get API configuration
359
+ api_config = node.implementation.get("api", {})
360
+
361
+ # Create HTTP client node
362
+ http_node = HTTPClientNode(
363
+ url=api_config.get("url", ""),
364
+ method=api_config.get("method", "GET"),
365
+ headers=api_config.get("headers", {}),
366
+ timeout=api_config.get("timeout", 30),
367
+ )
368
+
369
+ # Prepare request data
370
+ if api_config.get("method") in ["POST", "PUT", "PATCH"]:
371
+ # Include test data in body
372
+ result = await http_node.run(json_data=test_data)
373
+ else:
374
+ # Include test data as query params
375
+ result = await http_node.run(params=test_data)
376
+
377
+ return result