Ava2lon commited on
Commit
3e93464
·
verified ·
1 Parent(s): 8f0ced3

Upload 46 files

Browse files
Files changed (46) hide show
  1. __pycache__/flowforge_cli.cpython-314.pyc +0 -0
  2. app/__init__.py +1 -0
  3. app/__pycache__/__init__.cpython-314.pyc +0 -0
  4. app/__pycache__/main.cpython-314.pyc +0 -0
  5. app/api/__pycache__/routes.cpython-314.pyc +0 -0
  6. app/api/routes.py +423 -0
  7. app/core/__pycache__/config.cpython-314.pyc +0 -0
  8. app/core/__pycache__/rate_limit.cpython-314.pyc +0 -0
  9. app/core/__pycache__/security.cpython-314.pyc +0 -0
  10. app/core/config.py +36 -0
  11. app/core/rate_limit.py +45 -0
  12. app/core/security.py +65 -0
  13. app/main.py +36 -0
  14. app/models/__pycache__/catalog.cpython-314.pyc +0 -0
  15. app/models/__pycache__/workflow.cpython-314.pyc +0 -0
  16. app/models/catalog.py +34 -0
  17. app/models/workflow.py +461 -0
  18. app/services/__pycache__/adapters.cpython-314.pyc +0 -0
  19. app/services/__pycache__/catalog.cpython-314.pyc +0 -0
  20. app/services/__pycache__/chat.cpython-314.pyc +0 -0
  21. app/services/__pycache__/collaboration.cpython-314.pyc +0 -0
  22. app/services/__pycache__/deployment.cpython-314.pyc +0 -0
  23. app/services/__pycache__/generator.cpython-314.pyc +0 -0
  24. app/services/__pycache__/intelligence.cpython-314.pyc +0 -0
  25. app/services/__pycache__/operations.cpython-314.pyc +0 -0
  26. app/services/__pycache__/optimizer.cpython-314.pyc +0 -0
  27. app/services/__pycache__/remote.cpython-314.pyc +0 -0
  28. app/services/__pycache__/repository.cpython-314.pyc +0 -0
  29. app/services/__pycache__/validation.cpython-314.pyc +0 -0
  30. app/services/adapters.py +175 -0
  31. app/services/catalog.py +170 -0
  32. app/services/chat.py +84 -0
  33. app/services/collaboration.py +263 -0
  34. app/services/deployment.py +43 -0
  35. app/services/generator.py +271 -0
  36. app/services/intelligence.py +300 -0
  37. app/services/operations.py +230 -0
  38. app/services/optimizer.py +83 -0
  39. app/services/remote.py +66 -0
  40. app/services/repository.py +164 -0
  41. app/services/validation.py +215 -0
  42. requirements.txt +9 -3
  43. tests/__pycache__/conftest.cpython-314.pyc +0 -0
  44. tests/__pycache__/test_api.cpython-314.pyc +0 -0
  45. tests/conftest.py +14 -0
  46. tests/test_api.py +241 -0
__pycache__/flowforge_cli.cpython-314.pyc ADDED
Binary file (3.37 kB). View file
 
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FlowForge API package."""
app/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (151 Bytes). View file
 
app/__pycache__/main.cpython-314.pyc ADDED
Binary file (1.74 kB). View file
 
app/api/__pycache__/routes.cpython-314.pyc ADDED
Binary file (29.3 kB). View file
 
app/api/routes.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Annotated
3
+
4
+ import httpx
5
+ from fastapi import APIRouter, Depends, HTTPException, Query
6
+
7
+ from app.core.config import Settings, get_settings
8
+ from app.core.security import CurrentUser, get_current_user
9
+ from app.models.catalog import NodeDefinition, ProjectSummary, TemplateSummary
10
+ from app.models.workflow import (
11
+ ChatRequest,
12
+ ChatResponse,
13
+ CommentRequest,
14
+ ContractRequest,
15
+ ContractResponse,
16
+ CostEstimate,
17
+ CostEstimateRequest,
18
+ DeploymentRequest,
19
+ DeploymentResponse,
20
+ DependencyImpactRequest,
21
+ DependencyImpactResponse,
22
+ DocumentationResponse,
23
+ EnvironmentPromotionRequest,
24
+ EnvironmentPromotionResponse,
25
+ ExportRequest,
26
+ ExpressionRequest,
27
+ ExpressionResponse,
28
+ GenerateWorkflowRequest,
29
+ GenerateWorkflowResponse,
30
+ ImportRequest,
31
+ IntentDriftRequest,
32
+ IntentDriftResponse,
33
+ LineageResponse,
34
+ OptimizationResponse,
35
+ QualityResponse,
36
+ ReleasePlanRequest,
37
+ ReleasePlanResponse,
38
+ ReplayRequest,
39
+ RoiRequest,
40
+ RoiResponse,
41
+ SaveRequest,
42
+ SaveResponse,
43
+ ShareRequest,
44
+ ShareResponse,
45
+ SharedWorkflowResponse,
46
+ SimulationRequest,
47
+ SimulationResponse,
48
+ TestWorkflowRequest,
49
+ TestWorkflowResponse,
50
+ SelfHealRequest,
51
+ SelfHealResponse,
52
+ ValidationResult,
53
+ VersionSummary,
54
+ WorkflowComment,
55
+ WorkflowDiff,
56
+ WorkflowDiffRequest,
57
+ WorkflowDocument,
58
+ WorkflowPackageRequest,
59
+ WorkflowPackageResponse,
60
+ WorkflowRequest,
61
+ WebhookInspectRequest,
62
+ WebhookInspectResponse,
63
+ )
64
+ from app.services.adapters import adapters
65
+ from app.services.catalog import NODE_CATALOG, TEMPLATES
66
+ from app.services.chat import chat_service
67
+ from app.services.collaboration import CollaborationRepository
68
+ from app.services.deployment import deploy_to_n8n
69
+ from app.services.generator import providers
70
+ from app.services.intelligence import (
71
+ contract_check,
72
+ dependency_impact,
73
+ documentation,
74
+ inspect_webhook,
75
+ intent_drift,
76
+ lineage,
77
+ package_workflow,
78
+ promote,
79
+ quality,
80
+ release_plan,
81
+ roi,
82
+ self_heal,
83
+ )
84
+ from app.services.optimizer import optimizer
85
+ from app.services.operations import (
86
+ diff_workflows,
87
+ estimate_cost,
88
+ replay_from_node,
89
+ run_test,
90
+ simulate,
91
+ )
92
+ from app.services.repository import WorkflowRepository
93
+ from app.services.remote import fetch_remote_json
94
+ from app.services.validation import validator
95
+
96
+ router = APIRouter()
97
+ User = Annotated[CurrentUser, Depends(get_current_user)]
98
+ AppSettings = Annotated[Settings, Depends(get_settings)]
99
+
100
+
101
+ @router.post("/generate-workflow", response_model=GenerateWorkflowResponse)
102
+ async def generate_workflow(
103
+ request: GenerateWorkflowRequest,
104
+ user: User,
105
+ settings: AppSettings,
106
+ ) -> GenerateWorkflowResponse:
107
+ provider = providers.get(request.provider or settings.ai_provider)
108
+ return await provider.generate(request.prompt)
109
+
110
+
111
+ @router.post("/validate", response_model=ValidationResult)
112
+ async def validate_workflow(request: WorkflowRequest, user: User) -> ValidationResult:
113
+ return validator.validate(request.workflow)
114
+
115
+
116
+ @router.post("/optimize", response_model=OptimizationResponse)
117
+ async def optimize_workflow(request: WorkflowRequest, user: User) -> OptimizationResponse:
118
+ return optimizer.optimize(request.workflow)
119
+
120
+
121
+ @router.post("/simulate", response_model=SimulationResponse)
122
+ async def simulate_workflow(request: SimulationRequest, user: User) -> SimulationResponse:
123
+ return simulate(request.workflow, request.input_data)
124
+
125
+
126
+ @router.post("/test-workflow", response_model=TestWorkflowResponse)
127
+ async def test_workflow(request: TestWorkflowRequest, user: User) -> TestWorkflowResponse:
128
+ results = [run_test(request.workflow, case) for case in request.cases]
129
+ return TestWorkflowResponse(
130
+ passed=sum(result.passed for result in results),
131
+ failed=sum(not result.passed for result in results),
132
+ results=results,
133
+ )
134
+
135
+
136
+ @router.post("/estimate-cost", response_model=CostEstimate)
137
+ async def workflow_cost(request: CostEstimateRequest, user: User) -> CostEstimate:
138
+ return estimate_cost(request.workflow, request.executions_per_month)
139
+
140
+
141
+ @router.post("/diff", response_model=WorkflowDiff)
142
+ async def workflow_diff(request: WorkflowDiffRequest, user: User) -> WorkflowDiff:
143
+ return diff_workflows(request.before, request.after)
144
+
145
+
146
+ @router.post("/lineage", response_model=LineageResponse)
147
+ async def workflow_lineage(request: WorkflowRequest, user: User) -> LineageResponse:
148
+ return lineage(request.workflow)
149
+
150
+
151
+ @router.post("/contract-test", response_model=ContractResponse)
152
+ async def contract_test(request: ContractRequest, user: User) -> ContractResponse:
153
+ return contract_check(request.sample_data, request.expected_schema)
154
+
155
+
156
+ @router.post("/quality", response_model=QualityResponse)
157
+ async def workflow_quality(request: WorkflowRequest, user: User) -> QualityResponse:
158
+ return quality(request.workflow)
159
+
160
+
161
+ @router.post("/intent-drift", response_model=IntentDriftResponse)
162
+ async def workflow_intent_drift(
163
+ request: IntentDriftRequest, user: User
164
+ ) -> IntentDriftResponse:
165
+ return intent_drift(request.workflow, request.requirement)
166
+
167
+
168
+ @router.post("/replay", response_model=SimulationResponse)
169
+ async def replay(request: ReplayRequest, user: User) -> SimulationResponse:
170
+ return replay_from_node(request.workflow, request.node_id, request.input_data)
171
+
172
+
173
+ @router.post("/promote", response_model=EnvironmentPromotionResponse)
174
+ async def promote_environment(
175
+ request: EnvironmentPromotionRequest, user: User
176
+ ) -> EnvironmentPromotionResponse:
177
+ return promote(request.workflow, request.environment, request.values)
178
+
179
+
180
+ @router.post("/release-plan", response_model=ReleasePlanResponse)
181
+ async def create_release_plan(
182
+ request: ReleasePlanRequest, user: User
183
+ ) -> ReleasePlanResponse:
184
+ return release_plan(request)
185
+
186
+
187
+ @router.post("/package", response_model=WorkflowPackageResponse)
188
+ async def create_workflow_package(
189
+ request: WorkflowPackageRequest, user: User
190
+ ) -> WorkflowPackageResponse:
191
+ return package_workflow(request)
192
+
193
+
194
+ @router.post("/documentation", response_model=DocumentationResponse)
195
+ async def generate_documentation(
196
+ request: WorkflowRequest, user: User
197
+ ) -> DocumentationResponse:
198
+ return documentation(request.workflow)
199
+
200
+
201
+ @router.post("/roi", response_model=RoiResponse)
202
+ async def calculate_roi(request: RoiRequest, user: User) -> RoiResponse:
203
+ return roi(
204
+ request.workflow,
205
+ request.executions_per_month,
206
+ request.minutes_saved_per_execution,
207
+ request.hourly_rate_usd,
208
+ request.sla_minutes,
209
+ )
210
+
211
+
212
+ @router.post("/inspect-webhook", response_model=WebhookInspectResponse)
213
+ async def inspect_webhook_payload(
214
+ request: WebhookInspectRequest, user: User
215
+ ) -> WebhookInspectResponse:
216
+ return inspect_webhook(request.payload, request.redact)
217
+
218
+
219
+ @router.post("/dependency-impact", response_model=DependencyImpactResponse)
220
+ async def analyze_dependency_impact(
221
+ request: DependencyImpactRequest, user: User
222
+ ) -> DependencyImpactResponse:
223
+ return dependency_impact(request.workflow, request.dependency)
224
+
225
+
226
+ @router.post("/self-heal", response_model=SelfHealResponse)
227
+ async def propose_self_heal(request: SelfHealRequest, user: User) -> SelfHealResponse:
228
+ return self_heal(request.workflow, request.errors)
229
+
230
+
231
+ @router.post("/chat", response_model=ChatResponse)
232
+ async def chat(request: ChatRequest, user: User) -> ChatResponse:
233
+ return await chat_service.respond(request.message, request.workflow)
234
+
235
+
236
+ @router.post("/generate-expression", response_model=ExpressionResponse)
237
+ async def generate_expression(request: ExpressionRequest, user: User) -> ExpressionResponse:
238
+ description = request.description.lower()
239
+ if "email" in description:
240
+ expression = "={{ $json.email }}"
241
+ elif "name" in description:
242
+ expression = "={{ $json.name }}"
243
+ elif "current" in description and ("date" in description or "time" in description):
244
+ expression = "={{ $now }}"
245
+ elif "index" in description:
246
+ expression = "={{ $itemIndex }}"
247
+ elif request.context.get("node"):
248
+ node_name = str(request.context["node"])
249
+ expression = f'={{{{ $node["{node_name}"].json.data }}}}'
250
+ else:
251
+ expression = "={{ $json }}"
252
+ return ExpressionResponse(
253
+ expression=expression,
254
+ explanation="The expression reads data from the current n8n item at execution time.",
255
+ alternatives=["={{ $json }}", "={{ $itemIndex }}", "={{ $now }}"],
256
+ )
257
+
258
+
259
+ @router.post("/import", response_model=WorkflowDocument)
260
+ async def import_workflow(request: ImportRequest, user: User) -> WorkflowDocument:
261
+ content = request.content
262
+ if request.source in {"url", "github"}:
263
+ try:
264
+ content = await fetch_remote_json(
265
+ request.content,
266
+ github_only=request.source == "github",
267
+ )
268
+ except (ValueError, httpx.HTTPError, UnicodeDecodeError) as exc:
269
+ raise HTTPException(status_code=422, detail=f"Remote import failed: {exc}") from exc
270
+ adapter = adapters.importers.get(
271
+ "json" if request.source in {"url", "github"} else request.source
272
+ )
273
+ if not adapter:
274
+ raise HTTPException(status_code=400, detail="Unsupported import source")
275
+ try:
276
+ return adapter.load(content)
277
+ except (json.JSONDecodeError, ValueError, TypeError) as exc:
278
+ raise HTTPException(status_code=422, detail=f"Invalid workflow: {exc}") from exc
279
+
280
+
281
+ @router.post("/export")
282
+ async def export_workflow(request: ExportRequest, user: User):
283
+ if request.format == "internal":
284
+ return request.workflow.model_dump(mode="json")
285
+ adapter = adapters.exporters.get(request.format)
286
+ if not adapter:
287
+ raise HTTPException(status_code=400, detail="Unsupported export format")
288
+ return adapter.dump(request.workflow)
289
+
290
+
291
+ @router.get("/templates")
292
+ async def list_templates(
293
+ user: User,
294
+ q: str = Query(default="", max_length=200),
295
+ category: str | None = Query(default=None, max_length=80),
296
+ limit: int = Query(default=24, ge=1, le=100),
297
+ offset: int = Query(default=0, ge=0),
298
+ ) -> dict[str, list[TemplateSummary] | int]:
299
+ query = q.lower().strip()
300
+ items = [
301
+ template
302
+ for template in TEMPLATES
303
+ if (not query or query in f"{template.name} {template.description}".lower())
304
+ and (not category or template.category.lower() == category.lower())
305
+ ]
306
+ return {"items": items[offset : offset + limit], "total": len(items)}
307
+
308
+
309
+ @router.get("/nodes")
310
+ async def list_nodes(
311
+ user: User,
312
+ q: str = Query(default="", max_length=200),
313
+ category: str | None = Query(default=None, max_length=80),
314
+ limit: int = Query(default=100, ge=1, le=500),
315
+ offset: int = Query(default=0, ge=0),
316
+ ) -> dict[str, list[NodeDefinition] | int]:
317
+ query = q.lower().strip()
318
+ items = [
319
+ node
320
+ for node in NODE_CATALOG
321
+ if (
322
+ not query
323
+ or query in f"{node.displayName} {node.description} {node.type}".lower()
324
+ )
325
+ and (not category or node.category.lower() == category.lower())
326
+ ]
327
+ return {"items": items[offset : offset + limit], "total": len(items)}
328
+
329
+
330
+ @router.get("/projects", response_model=list[ProjectSummary])
331
+ async def list_projects(user: User, settings: AppSettings) -> list[ProjectSummary]:
332
+ return await WorkflowRepository(settings).projects(user.id)
333
+
334
+
335
+ @router.post("/save", response_model=SaveResponse)
336
+ async def save_workflow(
337
+ request: SaveRequest,
338
+ user: User,
339
+ settings: AppSettings,
340
+ ) -> SaveResponse:
341
+ try:
342
+ return await WorkflowRepository(settings).save(request, user.id)
343
+ except ValueError as exc:
344
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
345
+
346
+
347
+ @router.post("/shares", response_model=ShareResponse)
348
+ async def create_share(
349
+ request: ShareRequest, user: User, settings: AppSettings
350
+ ) -> ShareResponse:
351
+ try:
352
+ return await CollaborationRepository(settings).create_share(request, user.id)
353
+ except ValueError as exc:
354
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
355
+
356
+
357
+ @router.get("/shares/{token}", response_model=SharedWorkflowResponse)
358
+ async def get_shared_workflow(token: str, settings: AppSettings) -> SharedWorkflowResponse:
359
+ if len(token) < 32 or len(token) > 128:
360
+ raise HTTPException(status_code=404, detail="Share link was not found")
361
+ try:
362
+ return await CollaborationRepository(settings).shared_workflow(token)
363
+ except ValueError as exc:
364
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
365
+
366
+
367
+ @router.get("/workflows/{workflow_id}/versions", response_model=list[VersionSummary])
368
+ async def list_versions(
369
+ workflow_id: str, user: User, settings: AppSettings
370
+ ) -> list[VersionSummary]:
371
+ try:
372
+ return await CollaborationRepository(settings).versions(workflow_id, user.id)
373
+ except ValueError as exc:
374
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
375
+
376
+
377
+ @router.post("/workflows/{workflow_id}/versions/{version}/restore", response_model=WorkflowDocument)
378
+ async def restore_version(
379
+ workflow_id: str, version: int, user: User, settings: AppSettings
380
+ ) -> WorkflowDocument:
381
+ try:
382
+ return await CollaborationRepository(settings).restore(workflow_id, version, user.id)
383
+ except ValueError as exc:
384
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
385
+
386
+
387
+ @router.get("/workflows/{workflow_id}/comments", response_model=list[WorkflowComment])
388
+ async def list_comments(
389
+ workflow_id: str, user: User, settings: AppSettings
390
+ ) -> list[WorkflowComment]:
391
+ try:
392
+ return await CollaborationRepository(settings).comments(workflow_id, user.id)
393
+ except ValueError as exc:
394
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
395
+
396
+
397
+ @router.post("/comments", response_model=WorkflowComment)
398
+ async def add_comment(
399
+ request: CommentRequest, user: User, settings: AppSettings
400
+ ) -> WorkflowComment:
401
+ try:
402
+ return await CollaborationRepository(settings).add_comment(
403
+ request.workflow_id, request.body, request.node_id, user.id
404
+ )
405
+ except ValueError as exc:
406
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
407
+
408
+
409
+ @router.post("/deploy", response_model=DeploymentResponse)
410
+ async def deploy(
411
+ request: DeploymentRequest, user: User, settings: AppSettings
412
+ ) -> DeploymentResponse:
413
+ try:
414
+ authorized = False
415
+ if request.workflow.id:
416
+ authorized = await CollaborationRepository(settings).authorize_deploy(
417
+ request.workflow.id, user.id
418
+ )
419
+ return await deploy_to_n8n(
420
+ request.workflow, request.activate, settings, authorized=authorized
421
+ )
422
+ except (ValueError, httpx.HTTPError) as exc:
423
+ raise HTTPException(status_code=422, detail=f"Deployment failed: {exc}") from exc
app/core/__pycache__/config.cpython-314.pyc ADDED
Binary file (2.75 kB). View file
 
app/core/__pycache__/rate_limit.cpython-314.pyc ADDED
Binary file (3.79 kB). View file
 
app/core/__pycache__/security.cpython-314.pyc ADDED
Binary file (3.64 kB). View file
 
app/core/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from typing import Literal
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ app_name: str = "FlowForge API"
10
+ environment: Literal["development", "test", "production"] = "development"
11
+ frontend_url: str = "http://localhost:3000"
12
+ supabase_url: str = ""
13
+ supabase_anon_key: str = ""
14
+ supabase_service_role_key: str = ""
15
+ supabase_jwt_audience: str = "authenticated"
16
+ ai_provider: str = "mock"
17
+ openai_api_key: str = ""
18
+ n8n_base_url: str = ""
19
+ n8n_api_key: str = ""
20
+ rate_limit_per_minute: int = Field(default=120, ge=10, le=10_000)
21
+ auth_required: bool = False
22
+
23
+ model_config = SettingsConfigDict(
24
+ env_file=(".env", "../../.env"),
25
+ env_file_encoding="utf-8",
26
+ extra="ignore",
27
+ )
28
+
29
+ @property
30
+ def allowed_origins(self) -> list[str]:
31
+ return [origin.strip() for origin in self.frontend_url.split(",") if origin.strip()]
32
+
33
+
34
+ @lru_cache
35
+ def get_settings() -> Settings:
36
+ return Settings()
app/core/rate_limit.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ from collections import defaultdict, deque
4
+
5
+ from fastapi import Request
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.responses import JSONResponse
8
+
9
+
10
+ class RateLimitMiddleware(BaseHTTPMiddleware):
11
+ def __init__(self, app, requests_per_minute: int = 120):
12
+ super().__init__(app)
13
+ self.requests_per_minute = requests_per_minute
14
+ self.requests: dict[str, deque[float]] = defaultdict(deque)
15
+ self.lock = asyncio.Lock()
16
+
17
+ async def dispatch(self, request: Request, call_next):
18
+ if request.url.path in {"/health", "/docs", "/openapi.json"}:
19
+ return await call_next(request)
20
+
21
+ forwarded = request.headers.get("x-forwarded-for", "")
22
+ client_id = forwarded.split(",")[0].strip() or (
23
+ request.client.host if request.client else "unknown"
24
+ )
25
+ now = time.monotonic()
26
+
27
+ async with self.lock:
28
+ window = self.requests[client_id]
29
+ while window and now - window[0] > 60:
30
+ window.popleft()
31
+ if len(window) >= self.requests_per_minute:
32
+ retry_after = max(1, int(60 - (now - window[0])))
33
+ return JSONResponse(
34
+ status_code=429,
35
+ content={"detail": "Rate limit exceeded"},
36
+ headers={"Retry-After": str(retry_after)},
37
+ )
38
+ window.append(now)
39
+
40
+ response = await call_next(request)
41
+ response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
42
+ response.headers["X-RateLimit-Remaining"] = str(
43
+ max(0, self.requests_per_minute - len(self.requests[client_id]))
44
+ )
45
+ return response
app/core/security.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ import jwt
5
+ from fastapi import Depends, HTTPException, status
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+
8
+ from app.core.config import Settings, get_settings
9
+
10
+ bearer = HTTPBearer(auto_error=False)
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class CurrentUser:
15
+ id: str
16
+ email: str | None = None
17
+ role: str = "authenticated"
18
+
19
+
20
+ async def get_current_user(
21
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
22
+ settings: Annotated[Settings, Depends(get_settings)],
23
+ ) -> CurrentUser:
24
+ if credentials is None:
25
+ if settings.auth_required:
26
+ raise HTTPException(
27
+ status_code=status.HTTP_401_UNAUTHORIZED,
28
+ detail="Authentication required",
29
+ )
30
+ return CurrentUser(id="00000000-0000-0000-0000-000000000000", role="anonymous")
31
+
32
+ try:
33
+ if not settings.supabase_url:
34
+ if settings.environment == "production":
35
+ raise ValueError("SUPABASE_URL is not configured")
36
+ claims = jwt.decode(
37
+ credentials.credentials,
38
+ options={"verify_signature": False},
39
+ algorithms=["HS256", "RS256"],
40
+ )
41
+ else:
42
+ jwks_client = jwt.PyJWKClient(
43
+ f"{settings.supabase_url.rstrip('/')}/auth/v1/.well-known/jwks.json",
44
+ cache_jwk_set=True,
45
+ lifespan=3600,
46
+ )
47
+ signing_key = jwks_client.get_signing_key_from_jwt(credentials.credentials)
48
+ claims = jwt.decode(
49
+ credentials.credentials,
50
+ signing_key.key,
51
+ algorithms=["ES256", "RS256"],
52
+ audience=settings.supabase_jwt_audience,
53
+ options={"require": ["exp", "sub"]},
54
+ )
55
+ except (jwt.PyJWTError, ValueError) as exc:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Invalid or expired access token",
59
+ ) from exc
60
+
61
+ return CurrentUser(
62
+ id=str(claims["sub"]),
63
+ email=claims.get("email"),
64
+ role=claims.get("role", "authenticated"),
65
+ )
app/main.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ from app.api.routes import router
5
+ from app.core.config import get_settings
6
+ from app.core.rate_limit import RateLimitMiddleware
7
+
8
+ settings = get_settings()
9
+
10
+ app = FastAPI(
11
+ title=settings.app_name,
12
+ description=(
13
+ "Generate, validate, optimize, import, export, and persist n8n-compatible workflows."
14
+ ),
15
+ version="0.1.0",
16
+ docs_url="/docs",
17
+ redoc_url="/redoc",
18
+ openapi_url="/openapi.json",
19
+ )
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=settings.allowed_origins,
23
+ allow_credentials=True,
24
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
25
+ allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
26
+ )
27
+ app.add_middleware(
28
+ RateLimitMiddleware,
29
+ requests_per_minute=settings.rate_limit_per_minute,
30
+ )
31
+ app.include_router(router)
32
+
33
+
34
+ @app.get("/health", tags=["System"])
35
+ async def health() -> dict[str, str]:
36
+ return {"status": "ok", "service": settings.app_name, "version": "0.1.0"}
app/models/__pycache__/catalog.cpython-314.pyc ADDED
Binary file (2.51 kB). View file
 
app/models/__pycache__/workflow.cpython-314.pyc ADDED
Binary file (42.2 kB). View file
 
app/models/catalog.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class NodeDefinition(BaseModel):
7
+ type: str
8
+ displayName: str
9
+ description: str
10
+ category: str
11
+ icon: str
12
+ color: str
13
+ typeVersion: float = 1
14
+ defaults: dict[str, Any] = Field(default_factory=dict)
15
+ credentials: list[str] = Field(default_factory=list)
16
+ community: bool = False
17
+
18
+
19
+ class TemplateSummary(BaseModel):
20
+ id: str
21
+ name: str
22
+ description: str
23
+ category: str
24
+ node_count: int
25
+ use_count: int = 0
26
+ tags: list[str] = Field(default_factory=list)
27
+
28
+
29
+ class ProjectSummary(BaseModel):
30
+ id: str
31
+ name: str
32
+ description: str | None = None
33
+ workflow_count: int = 0
34
+ updated_at: str
app/models/workflow.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
5
+
6
+
7
+ class Position(BaseModel):
8
+ x: float
9
+ y: float
10
+
11
+
12
+ class CredentialPlaceholder(BaseModel):
13
+ id: str = "__CREDENTIAL_ID__"
14
+ name: str
15
+ type: str
16
+
17
+
18
+ class WorkflowNodeData(BaseModel):
19
+ model_config = ConfigDict(extra="allow")
20
+
21
+ label: str = Field(min_length=1, max_length=128)
22
+ type: str = Field(min_length=1, max_length=256)
23
+ typeVersion: float = Field(default=1, ge=0)
24
+ category: Literal[
25
+ "trigger", "core", "ai", "database", "communication", "cloud", "developer"
26
+ ]
27
+ subtitle: str | None = Field(default=None, max_length=256)
28
+ parameters: dict[str, Any] = Field(default_factory=dict)
29
+ credentials: dict[str, CredentialPlaceholder] | None = None
30
+ disabled: bool = False
31
+ issues: int = 0
32
+
33
+
34
+ class WorkflowNode(BaseModel):
35
+ id: str = Field(min_length=1, max_length=128)
36
+ type: str = "workflow"
37
+ position: Position
38
+ data: WorkflowNodeData
39
+ selected: bool | None = None
40
+
41
+
42
+ class WorkflowEdge(BaseModel):
43
+ id: str = Field(min_length=1, max_length=256)
44
+ source: str
45
+ target: str
46
+ type: str = "smoothstep"
47
+ animated: bool = False
48
+ sourceHandle: str | None = None
49
+ targetHandle: str | None = None
50
+
51
+
52
+ class WorkflowMeta(BaseModel):
53
+ model_config = ConfigDict(extra="allow")
54
+
55
+ description: str | None = None
56
+ generatedBy: str | None = None
57
+ version: int | None = None
58
+ tags: list[str] = Field(default_factory=list)
59
+
60
+
61
+ class WorkflowDocument(BaseModel):
62
+ id: str | None = None
63
+ name: str = Field(min_length=1, max_length=160)
64
+ active: bool = False
65
+ nodes: list[WorkflowNode] = Field(default_factory=list, max_length=1000)
66
+ edges: list[WorkflowEdge] = Field(default_factory=list, max_length=5000)
67
+ settings: dict[str, Any] = Field(default_factory=dict)
68
+ meta: WorkflowMeta = Field(default_factory=WorkflowMeta)
69
+ pinData: dict[str, Any] = Field(default_factory=dict)
70
+
71
+ @field_validator("nodes")
72
+ @classmethod
73
+ def unique_node_ids(cls, nodes: list[WorkflowNode]) -> list[WorkflowNode]:
74
+ ids = [node.id for node in nodes]
75
+ if len(ids) != len(set(ids)):
76
+ raise ValueError("Node IDs must be unique")
77
+ return nodes
78
+
79
+ @field_validator("edges")
80
+ @classmethod
81
+ def unique_edge_ids(cls, edges: list[WorkflowEdge]) -> list[WorkflowEdge]:
82
+ ids = [edge.id for edge in edges]
83
+ if len(ids) != len(set(ids)):
84
+ raise ValueError("Edge IDs must be unique")
85
+ return edges
86
+
87
+
88
+ class GenerateWorkflowRequest(BaseModel):
89
+ prompt: str = Field(min_length=10, max_length=20_000)
90
+ provider: str | None = None
91
+ model: str | None = None
92
+
93
+
94
+ class GenerateWorkflowResponse(BaseModel):
95
+ workflow: WorkflowDocument
96
+ explanation: str
97
+ warnings: list[str] = Field(default_factory=list)
98
+
99
+
100
+ class WorkflowRequest(BaseModel):
101
+ workflow: WorkflowDocument
102
+
103
+
104
+ class ValidationIssue(BaseModel):
105
+ code: str
106
+ severity: Literal["error", "warning", "info"]
107
+ message: str
108
+ nodeId: str | None = None
109
+ suggestion: str | None = None
110
+
111
+
112
+ class ValidationResult(BaseModel):
113
+ valid: bool
114
+ score: int = Field(ge=0, le=100)
115
+ issues: list[ValidationIssue]
116
+
117
+
118
+ class OptimizationSuggestion(BaseModel):
119
+ title: str
120
+ description: str
121
+ impact: Literal["low", "medium", "high"]
122
+ nodeIds: list[str] = Field(default_factory=list)
123
+
124
+
125
+ class OptimizationResponse(BaseModel):
126
+ workflow: WorkflowDocument
127
+ suggestions: list[OptimizationSuggestion]
128
+
129
+
130
+ class ChatRequest(BaseModel):
131
+ message: str = Field(min_length=1, max_length=20_000)
132
+ workflow: WorkflowDocument
133
+ conversation_id: str | None = None
134
+
135
+
136
+ class ChatResponse(BaseModel):
137
+ message: str
138
+ workflow: WorkflowDocument | None = None
139
+ actions: list[str] = Field(default_factory=list)
140
+
141
+
142
+ class ExpressionRequest(BaseModel):
143
+ description: str = Field(min_length=2, max_length=4000)
144
+ context: dict[str, Any] = Field(default_factory=dict)
145
+
146
+
147
+ class ExpressionResponse(BaseModel):
148
+ expression: str
149
+ explanation: str
150
+ alternatives: list[str] = Field(default_factory=list)
151
+
152
+
153
+ class ImportRequest(BaseModel):
154
+ content: str = Field(min_length=2, max_length=5_000_000)
155
+ source: Literal["json", "clipboard", "url", "github"] = "json"
156
+
157
+
158
+ class ExportRequest(BaseModel):
159
+ workflow: WorkflowDocument
160
+ format: Literal["n8n", "internal"] = "n8n"
161
+
162
+
163
+ class SaveRequest(BaseModel):
164
+ workflow: WorkflowDocument
165
+ project_id: str | None = None
166
+ change_summary: str = Field(default="Manual save", max_length=500)
167
+
168
+
169
+ class SaveResponse(BaseModel):
170
+ id: str
171
+ version: int
172
+ saved_at: str
173
+
174
+
175
+ class SimulationRequest(BaseModel):
176
+ workflow: WorkflowDocument
177
+ input_data: dict[str, Any] = Field(default_factory=dict)
178
+
179
+
180
+ class NodeRunResult(BaseModel):
181
+ node_id: str
182
+ node_name: str
183
+ status: Literal["success", "skipped", "error"]
184
+ duration_ms: int = Field(ge=0)
185
+ input_data: dict[str, Any] = Field(default_factory=dict)
186
+ output_data: dict[str, Any] = Field(default_factory=dict)
187
+ error: str | None = None
188
+
189
+
190
+ class SimulationResponse(BaseModel):
191
+ status: Literal["success", "error"]
192
+ duration_ms: int = Field(ge=0)
193
+ trace: list[NodeRunResult]
194
+ output_data: dict[str, Any] = Field(default_factory=dict)
195
+ warnings: list[str] = Field(default_factory=list)
196
+
197
+
198
+ class TestAssertion(BaseModel):
199
+ path: str = Field(min_length=1, max_length=500)
200
+ operator: Literal["equals", "not_equals", "exists", "contains"] = "equals"
201
+ expected: Any = None
202
+
203
+
204
+ class WorkflowTestCase(BaseModel):
205
+ name: str = Field(min_length=1, max_length=160)
206
+ input_data: dict[str, Any] = Field(default_factory=dict)
207
+ assertions: list[TestAssertion] = Field(default_factory=list, max_length=100)
208
+
209
+
210
+ class TestWorkflowRequest(BaseModel):
211
+ workflow: WorkflowDocument
212
+ cases: list[WorkflowTestCase] = Field(min_length=1, max_length=100)
213
+
214
+
215
+ class TestCaseResult(BaseModel):
216
+ name: str
217
+ passed: bool
218
+ failures: list[str] = Field(default_factory=list)
219
+ duration_ms: int = Field(ge=0)
220
+
221
+
222
+ class TestWorkflowResponse(BaseModel):
223
+ passed: int
224
+ failed: int
225
+ results: list[TestCaseResult]
226
+
227
+
228
+ class CostEstimate(BaseModel):
229
+ executions_per_month: int
230
+ estimated_api_calls: int
231
+ estimated_ai_tokens: int
232
+ estimated_monthly_usd: float
233
+ assumptions: list[str]
234
+ rate_limit_warnings: list[str] = Field(default_factory=list)
235
+
236
+
237
+ class CostEstimateRequest(BaseModel):
238
+ workflow: WorkflowDocument
239
+ executions_per_month: int = Field(default=1000, ge=1, le=100_000_000)
240
+
241
+
242
+ class WorkflowDiffRequest(BaseModel):
243
+ before: WorkflowDocument
244
+ after: WorkflowDocument
245
+
246
+
247
+ class WorkflowDiff(BaseModel):
248
+ added_nodes: list[str] = Field(default_factory=list)
249
+ removed_nodes: list[str] = Field(default_factory=list)
250
+ modified_nodes: list[str] = Field(default_factory=list)
251
+ moved_nodes: list[str] = Field(default_factory=list)
252
+ added_edges: int = 0
253
+ removed_edges: int = 0
254
+
255
+
256
+ class ShareRequest(BaseModel):
257
+ workflow_id: str
258
+ workflow: WorkflowDocument | None = None
259
+ permission: Literal["view", "copy"] = "view"
260
+ expires_in_days: int | None = Field(default=30, ge=1, le=365)
261
+
262
+
263
+ class ShareResponse(BaseModel):
264
+ id: str
265
+ url: str
266
+ permission: Literal["view", "copy"]
267
+ expires_at: datetime | None = None
268
+
269
+
270
+ class SharedWorkflowResponse(BaseModel):
271
+ workflow: WorkflowDocument
272
+ permission: Literal["view", "copy"]
273
+ expires_at: datetime | None = None
274
+
275
+
276
+ class VersionSummary(BaseModel):
277
+ id: str
278
+ version: int
279
+ change_summary: str | None = None
280
+ created_at: str
281
+ created_by: str
282
+
283
+
284
+ class RestoreVersionRequest(BaseModel):
285
+ version: int = Field(ge=1)
286
+
287
+
288
+ class CommentRequest(BaseModel):
289
+ workflow_id: str
290
+ body: str = Field(min_length=1, max_length=10_000)
291
+ node_id: str | None = Field(default=None, max_length=128)
292
+
293
+
294
+ class WorkflowComment(BaseModel):
295
+ id: str
296
+ workflow_id: str
297
+ user_id: str
298
+ node_id: str | None = None
299
+ body: str
300
+ resolved_at: str | None = None
301
+ created_at: str
302
+
303
+
304
+ class DeploymentRequest(BaseModel):
305
+ workflow: WorkflowDocument
306
+ activate: bool = False
307
+
308
+
309
+ class DeploymentResponse(BaseModel):
310
+ status: Literal["deployed", "preview"]
311
+ remote_workflow_id: str | None = None
312
+ message: str
313
+
314
+
315
+ class LineageField(BaseModel):
316
+ field: str
317
+ source_nodes: list[str] = Field(default_factory=list)
318
+ consumer_nodes: list[str] = Field(default_factory=list)
319
+ classification: Literal["public", "internal", "personal", "financial", "secret"]
320
+
321
+
322
+ class LineageResponse(BaseModel):
323
+ fields: list[LineageField]
324
+ node_dependencies: dict[str, list[str]]
325
+ sensitive_paths: list[str] = Field(default_factory=list)
326
+
327
+
328
+ class ContractRequest(BaseModel):
329
+ workflow: WorkflowDocument
330
+ sample_data: dict[str, Any] = Field(default_factory=dict)
331
+ expected_schema: dict[str, Literal["string", "number", "boolean", "object", "array", "null"]]
332
+
333
+
334
+ class ContractResponse(BaseModel):
335
+ valid: bool
336
+ inferred_schema: dict[str, str]
337
+ violations: list[str] = Field(default_factory=list)
338
+
339
+
340
+ class QualityResponse(BaseModel):
341
+ overall: int = Field(ge=0, le=100)
342
+ scores: dict[str, int]
343
+ findings: list[ValidationIssue]
344
+
345
+
346
+ class IntentDriftRequest(BaseModel):
347
+ workflow: WorkflowDocument
348
+ requirement: str = Field(min_length=10, max_length=20_000)
349
+
350
+
351
+ class IntentDriftResponse(BaseModel):
352
+ alignment_score: int = Field(ge=0, le=100)
353
+ covered_terms: list[str]
354
+ missing_terms: list[str]
355
+
356
+
357
+ class ReplayRequest(BaseModel):
358
+ workflow: WorkflowDocument
359
+ node_id: str = Field(min_length=1, max_length=128)
360
+ input_data: dict[str, Any] = Field(default_factory=dict)
361
+
362
+
363
+ class EnvironmentPromotionRequest(BaseModel):
364
+ workflow: WorkflowDocument
365
+ environment: Literal["development", "staging", "production"]
366
+ values: dict[str, str | int | float | bool] = Field(default_factory=dict)
367
+
368
+
369
+ class EnvironmentPromotionResponse(BaseModel):
370
+ workflow: WorkflowDocument
371
+ environment: str
372
+ replacements: int
373
+ unresolved: list[str] = Field(default_factory=list)
374
+
375
+
376
+ class ReleasePlanRequest(BaseModel):
377
+ workflow: WorkflowDocument
378
+ strategy: Literal["shadow", "canary", "synthetic"]
379
+ traffic_percentage: int = Field(default=10, ge=0, le=100)
380
+ success_threshold: float = Field(default=0.99, ge=0, le=1)
381
+ max_error_rate: float = Field(default=0.02, ge=0, le=1)
382
+
383
+
384
+ class ReleasePlanResponse(BaseModel):
385
+ strategy: str
386
+ status: Literal["draft", "blocked"]
387
+ requires_approval: bool = True
388
+ steps: list[str]
389
+ rollback_conditions: list[str]
390
+ warnings: list[str] = Field(default_factory=list)
391
+
392
+
393
+ class WorkflowPackageRequest(BaseModel):
394
+ workflow: WorkflowDocument
395
+ tests: list[WorkflowTestCase] = Field(default_factory=list)
396
+ contracts: dict[str, Any] = Field(default_factory=dict)
397
+ environments: dict[str, dict[str, Any]] = Field(default_factory=dict)
398
+
399
+
400
+ class WorkflowPackageResponse(BaseModel):
401
+ manifest: dict[str, Any]
402
+ workflow: WorkflowDocument
403
+ tests: list[WorkflowTestCase]
404
+ contracts: dict[str, Any]
405
+ environments: dict[str, dict[str, Any]]
406
+
407
+
408
+ class DocumentationResponse(BaseModel):
409
+ markdown: str
410
+
411
+
412
+ class RoiRequest(BaseModel):
413
+ workflow: WorkflowDocument
414
+ executions_per_month: int = Field(default=1000, ge=1, le=100_000_000)
415
+ minutes_saved_per_execution: float = Field(default=5, ge=0, le=100_000)
416
+ hourly_rate_usd: float = Field(default=30, ge=0, le=100_000)
417
+ sla_minutes: float = Field(default=60, gt=0, le=1_000_000)
418
+
419
+
420
+ class RoiResponse(BaseModel):
421
+ hours_saved: float
422
+ labor_value_usd: float
423
+ estimated_operating_cost_usd: float
424
+ net_value_usd: float
425
+ estimated_duration_ms: int
426
+ sla_headroom_percent: float
427
+
428
+
429
+ class WebhookInspectRequest(BaseModel):
430
+ payload: dict[str, Any]
431
+ redact: bool = True
432
+
433
+
434
+ class WebhookInspectResponse(BaseModel):
435
+ payload: dict[str, Any]
436
+ schema_map: dict[str, str]
437
+ redacted_fields: list[str]
438
+
439
+
440
+ class DependencyImpactRequest(BaseModel):
441
+ workflow: WorkflowDocument
442
+ dependency: str = Field(min_length=1, max_length=500)
443
+
444
+
445
+ class DependencyImpactResponse(BaseModel):
446
+ affected_nodes: list[str]
447
+ downstream_nodes: list[str]
448
+ severity: Literal["none", "low", "medium", "high"]
449
+
450
+
451
+ class SelfHealRequest(BaseModel):
452
+ workflow: WorkflowDocument
453
+ errors: list[str] = Field(default_factory=list, max_length=100)
454
+
455
+
456
+ class SelfHealResponse(BaseModel):
457
+ proposed_workflow: WorkflowDocument
458
+ changes: list[str]
459
+ quality_before: int
460
+ quality_after: int
461
+ requires_approval: bool = True
app/services/__pycache__/adapters.cpython-314.pyc ADDED
Binary file (12 kB). View file
 
app/services/__pycache__/catalog.cpython-314.pyc ADDED
Binary file (4.02 kB). View file
 
app/services/__pycache__/chat.cpython-314.pyc ADDED
Binary file (4.85 kB). View file
 
app/services/__pycache__/collaboration.cpython-314.pyc ADDED
Binary file (19.1 kB). View file
 
app/services/__pycache__/deployment.cpython-314.pyc ADDED
Binary file (3.09 kB). View file
 
app/services/__pycache__/generator.cpython-314.pyc ADDED
Binary file (11.1 kB). View file
 
app/services/__pycache__/intelligence.cpython-314.pyc ADDED
Binary file (25.6 kB). View file
 
app/services/__pycache__/operations.cpython-314.pyc ADDED
Binary file (14.9 kB). View file
 
app/services/__pycache__/optimizer.cpython-314.pyc ADDED
Binary file (4.65 kB). View file
 
app/services/__pycache__/remote.cpython-314.pyc ADDED
Binary file (4.53 kB). View file
 
app/services/__pycache__/repository.cpython-314.pyc ADDED
Binary file (9.53 kB). View file
 
app/services/__pycache__/validation.cpython-314.pyc ADDED
Binary file (15.4 kB). View file
 
app/services/adapters.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any, Protocol
3
+
4
+ from app.models.workflow import (
5
+ CredentialPlaceholder,
6
+ Position,
7
+ WorkflowDocument,
8
+ WorkflowEdge,
9
+ WorkflowMeta,
10
+ WorkflowNode,
11
+ WorkflowNodeData,
12
+ )
13
+
14
+
15
+ def infer_category(node_type: str) -> str:
16
+ lowered = node_type.lower()
17
+ if "trigger" in lowered or lowered.endswith(".webhook"):
18
+ return "trigger"
19
+ if "langchain" in lowered or "openai" in lowered or "agent" in lowered:
20
+ return "ai"
21
+ if any(term in lowered for term in ("postgres", "mysql", "supabase", "mongo", "redis")):
22
+ return "database"
23
+ if any(term in lowered for term in ("slack", "gmail", "email", "discord", "teams")):
24
+ return "communication"
25
+ if any(term in lowered for term in ("code", "function", "graphql")):
26
+ return "developer"
27
+ return "core"
28
+
29
+
30
+ class ImportAdapter(Protocol):
31
+ def load(self, content: str) -> WorkflowDocument: ...
32
+
33
+
34
+ class ExportAdapter(Protocol):
35
+ def dump(self, workflow: WorkflowDocument) -> dict[str, Any]: ...
36
+
37
+
38
+ class N8nJsonAdapter:
39
+ def load(self, content: str) -> WorkflowDocument:
40
+ payload = json.loads(content)
41
+ if not isinstance(payload, dict):
42
+ raise ValueError("n8n workflow must be a JSON object")
43
+ raw_nodes = payload.get("nodes", [])
44
+ labels_to_ids = {
45
+ node.get("name", node.get("id", f"node-{index}")): node.get(
46
+ "id", f"node-{index}"
47
+ )
48
+ for index, node in enumerate(raw_nodes)
49
+ }
50
+ nodes = []
51
+ for index, node in enumerate(raw_nodes):
52
+ node_type = node.get("type", "n8n-nodes-base.noOp")
53
+ position = node.get("position", [80 + index * 300, 200])
54
+ credentials = {
55
+ key: CredentialPlaceholder(
56
+ id=str(value.get("id", "__CREDENTIAL_ID__")),
57
+ name=str(value.get("name", f"Connect {key}")),
58
+ type=key,
59
+ )
60
+ for key, value in (node.get("credentials") or {}).items()
61
+ }
62
+ nodes.append(
63
+ WorkflowNode(
64
+ id=str(node.get("id", f"node-{index}")),
65
+ position=Position(x=position[0], y=position[1]),
66
+ data=WorkflowNodeData(
67
+ label=node.get("name", f"Node {index + 1}"),
68
+ type=node_type,
69
+ typeVersion=node.get("typeVersion", 1),
70
+ category=infer_category(node_type), # type: ignore[arg-type]
71
+ parameters=node.get("parameters", {}),
72
+ credentials=credentials or None,
73
+ disabled=node.get("disabled", False),
74
+ ),
75
+ )
76
+ )
77
+
78
+ edges: list[WorkflowEdge] = []
79
+ for source_name, output_groups in payload.get("connections", {}).items():
80
+ source_id = labels_to_ids.get(source_name)
81
+ if not source_id:
82
+ continue
83
+ for output_type, indexes in output_groups.items():
84
+ for output_index, connections in enumerate(indexes):
85
+ for connection_index, connection in enumerate(connections):
86
+ target_id = labels_to_ids.get(connection.get("node"))
87
+ if not target_id:
88
+ continue
89
+ edges.append(
90
+ WorkflowEdge(
91
+ id=(
92
+ f"{source_id}-{target_id}-{output_type}-"
93
+ f"{output_index}-{connection_index}"
94
+ ),
95
+ source=source_id,
96
+ target=target_id,
97
+ )
98
+ )
99
+
100
+ return WorkflowDocument(
101
+ id=str(payload["id"]) if payload.get("id") else None,
102
+ name=payload.get("name", "Imported workflow"),
103
+ active=payload.get("active", False),
104
+ nodes=nodes,
105
+ edges=edges,
106
+ settings=payload.get("settings", {}),
107
+ meta=WorkflowMeta(
108
+ description=(payload.get("meta") or {}).get("description"),
109
+ generatedBy="Imported from n8n",
110
+ tags=[
111
+ tag.get("name", str(tag)) if isinstance(tag, dict) else str(tag)
112
+ for tag in payload.get("tags", [])
113
+ ],
114
+ ),
115
+ pinData=payload.get("pinData", {}),
116
+ )
117
+
118
+ def dump(self, workflow: WorkflowDocument) -> dict[str, Any]:
119
+ connections: dict[str, dict[str, list[list[dict[str, Any]]]]] = {}
120
+ nodes_by_id = {node.id: node for node in workflow.nodes}
121
+ for edge in workflow.edges:
122
+ source = nodes_by_id.get(edge.source)
123
+ target = nodes_by_id.get(edge.target)
124
+ if not source or not target:
125
+ continue
126
+ outputs = connections.setdefault(source.data.label, {"main": [[]]})
127
+ outputs["main"][0].append(
128
+ {"node": target.data.label, "type": "main", "index": 0}
129
+ )
130
+
131
+ return {
132
+ "name": workflow.name,
133
+ "active": workflow.active,
134
+ "nodes": [
135
+ {
136
+ "id": node.id,
137
+ "name": node.data.label,
138
+ "type": node.data.type,
139
+ "typeVersion": node.data.typeVersion,
140
+ "position": [round(node.position.x), round(node.position.y)],
141
+ "parameters": node.data.parameters,
142
+ "credentials": {
143
+ key: {"id": value.id, "name": value.name}
144
+ for key, value in (node.data.credentials or {}).items()
145
+ },
146
+ **({"disabled": True} if node.data.disabled else {}),
147
+ }
148
+ for node in workflow.nodes
149
+ ],
150
+ "connections": connections,
151
+ "settings": workflow.settings,
152
+ "staticData": None,
153
+ "meta": workflow.meta.model_dump(exclude_none=True),
154
+ "pinData": workflow.pinData,
155
+ "tags": [{"name": tag} for tag in workflow.meta.tags],
156
+ }
157
+
158
+
159
+ class AdapterRegistry:
160
+ def __init__(self) -> None:
161
+ self.importers: dict[str, ImportAdapter] = {}
162
+ self.exporters: dict[str, ExportAdapter] = {}
163
+
164
+ def register_importer(self, name: str, adapter: ImportAdapter) -> None:
165
+ self.importers[name] = adapter
166
+
167
+ def register_exporter(self, name: str, adapter: ExportAdapter) -> None:
168
+ self.exporters[name] = adapter
169
+
170
+
171
+ adapters = AdapterRegistry()
172
+ n8n_adapter = N8nJsonAdapter()
173
+ adapters.register_importer("json", n8n_adapter)
174
+ adapters.register_importer("clipboard", n8n_adapter)
175
+ adapters.register_exporter("n8n", n8n_adapter)
app/services/catalog.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.models.catalog import NodeDefinition, TemplateSummary
2
+
3
+
4
+ NODE_CATALOG = [
5
+ NodeDefinition(
6
+ type="n8n-nodes-base.webhook",
7
+ displayName="Webhook",
8
+ description="Start a workflow from an HTTP request",
9
+ category="trigger",
10
+ icon="Webhook",
11
+ color="#e6573d",
12
+ typeVersion=2,
13
+ defaults={"httpMethod": "POST", "path": ""},
14
+ ),
15
+ NodeDefinition(
16
+ type="n8n-nodes-base.gmailTrigger",
17
+ displayName="Gmail Trigger",
18
+ description="Run when a Gmail message arrives",
19
+ category="trigger",
20
+ icon="Mail",
21
+ color="#ea4335",
22
+ typeVersion=1.2,
23
+ defaults={"filters": {"hasAttachment": True}},
24
+ credentials=["gmailOAuth2"],
25
+ ),
26
+ NodeDefinition(
27
+ type="n8n-nodes-base.scheduleTrigger",
28
+ displayName="Schedule Trigger",
29
+ description="Run on a schedule",
30
+ category="trigger",
31
+ icon="Clock3",
32
+ color="#5a67d8",
33
+ typeVersion=1.2,
34
+ defaults={"rule": {"interval": [{"field": "hours", "hoursInterval": 1}]}},
35
+ ),
36
+ NodeDefinition(
37
+ type="n8n-nodes-base.httpRequest",
38
+ displayName="HTTP Request",
39
+ description="Call any REST API",
40
+ category="core",
41
+ icon="Globe2",
42
+ color="#3a7afe",
43
+ typeVersion=4.2,
44
+ defaults={"method": "GET", "url": ""},
45
+ ),
46
+ NodeDefinition(
47
+ type="n8n-nodes-base.code",
48
+ displayName="Code",
49
+ description="Transform data with JavaScript or Python",
50
+ category="developer",
51
+ icon="Code2",
52
+ color="#313a4d",
53
+ typeVersion=2,
54
+ defaults={"mode": "runOnceForAllItems", "jsCode": "return $input.all();"},
55
+ ),
56
+ NodeDefinition(
57
+ type="n8n-nodes-base.if",
58
+ displayName="If",
59
+ description="Branch based on conditions",
60
+ category="core",
61
+ icon="GitBranch",
62
+ color="#8b5cf6",
63
+ typeVersion=2.2,
64
+ defaults={"conditions": {"options": {}, "conditions": []}},
65
+ ),
66
+ NodeDefinition(
67
+ type="n8n-nodes-base.set",
68
+ displayName="Edit Fields",
69
+ description="Set or transform item fields",
70
+ category="core",
71
+ icon="Braces",
72
+ color="#65809b",
73
+ typeVersion=3.4,
74
+ defaults={"assignments": {"assignments": []}},
75
+ ),
76
+ NodeDefinition(
77
+ type="n8n-nodes-base.slack",
78
+ displayName="Slack",
79
+ description="Send messages and manage Slack resources",
80
+ category="communication",
81
+ icon="MessageSquare",
82
+ color="#4a154b",
83
+ typeVersion=2.2,
84
+ defaults={"resource": "message", "operation": "send"},
85
+ credentials=["slackOAuth2Api"],
86
+ ),
87
+ NodeDefinition(
88
+ type="n8n-nodes-base.supabase",
89
+ displayName="Supabase",
90
+ description="Read and write Supabase records",
91
+ category="database",
92
+ icon="Database",
93
+ color="#3ecf8e",
94
+ defaults={"operation": "getAll", "tableId": ""},
95
+ credentials=["supabaseApi"],
96
+ ),
97
+ NodeDefinition(
98
+ type="@n8n/n8n-nodes-langchain.openAi",
99
+ displayName="OpenAI",
100
+ description="Use OpenAI models in AI workflows",
101
+ category="ai",
102
+ icon="Sparkles",
103
+ color="#10a37f",
104
+ typeVersion=1.8,
105
+ defaults={"modelId": "gpt-4.1-mini"},
106
+ credentials=["openAiApi"],
107
+ ),
108
+ NodeDefinition(
109
+ type="@n8n/n8n-nodes-langchain.agent",
110
+ displayName="AI Agent",
111
+ description="Build a tool-using AI agent",
112
+ category="ai",
113
+ icon="Bot",
114
+ color="#f97316",
115
+ typeVersion=1.7,
116
+ defaults={"promptType": "define", "text": "={{ $json.chatInput }}"},
117
+ ),
118
+ NodeDefinition(
119
+ type="n8n-nodes-base.postgres",
120
+ displayName="Postgres",
121
+ description="Execute queries against PostgreSQL",
122
+ category="database",
123
+ icon="Cylinder",
124
+ color="#336791",
125
+ typeVersion=2.6,
126
+ defaults={"operation": "executeQuery", "query": ""},
127
+ credentials=["postgres"],
128
+ ),
129
+ ]
130
+
131
+ SUPPORTED_NODE_TYPES = {node.type for node in NODE_CATALOG}
132
+
133
+ TEMPLATES = [
134
+ TemplateSummary(
135
+ id="invoice-intake",
136
+ name="AI invoice intake",
137
+ description="Extract invoice data from Gmail attachments and store it in Supabase.",
138
+ category="Finance",
139
+ node_count=6,
140
+ use_count=1842,
141
+ tags=["AI", "Gmail", "Supabase", "Slack"],
142
+ ),
143
+ TemplateSummary(
144
+ id="lead-enrichment",
145
+ name="Lead enrichment pipeline",
146
+ description="Enrich new CRM leads and route qualified accounts to sales.",
147
+ category="CRM",
148
+ node_count=9,
149
+ use_count=1270,
150
+ tags=["CRM", "AI", "Sales"],
151
+ ),
152
+ TemplateSummary(
153
+ id="support-triage",
154
+ name="Support ticket triage",
155
+ description="Classify, prioritize, and assign incoming support tickets.",
156
+ category="AI",
157
+ node_count=8,
158
+ use_count=966,
159
+ tags=["AI", "Support", "Slack"],
160
+ ),
161
+ TemplateSummary(
162
+ id="content-repurposing",
163
+ name="Content repurposing",
164
+ description="Turn long-form content into scheduled social posts.",
165
+ category="Social Media",
166
+ node_count=11,
167
+ use_count=2213,
168
+ tags=["Marketing", "AI", "Social"],
169
+ ),
170
+ ]
app/services/chat.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from copy import deepcopy
2
+
3
+ from app.models.workflow import ChatResponse, WorkflowDocument
4
+ from app.services.optimizer import optimizer
5
+ from app.services.validation import validator
6
+
7
+
8
+ class WorkflowChatService:
9
+ async def respond(self, message: str, workflow: WorkflowDocument) -> ChatResponse:
10
+ lowered = message.lower()
11
+
12
+ if "optimize" in lowered or "performance" in lowered:
13
+ result = optimizer.optimize(workflow)
14
+ return ChatResponse(
15
+ message=(
16
+ f"I reviewed the workflow and applied {len(result.suggestions)} "
17
+ "deterministic improvements."
18
+ ),
19
+ workflow=result.workflow,
20
+ actions=[suggestion.title for suggestion in result.suggestions],
21
+ )
22
+
23
+ if "valid" in lowered or "fix" in lowered:
24
+ result = validator.validate(workflow)
25
+ actions = [
26
+ issue.suggestion or issue.message
27
+ for issue in result.issues[:5]
28
+ ]
29
+ return ChatResponse(
30
+ message=(
31
+ f"The workflow scores {result.score}/100 with "
32
+ f"{len(result.issues)} findings. I listed the highest-priority fixes."
33
+ ),
34
+ actions=actions or ["No validation issues found"],
35
+ )
36
+
37
+ if "retry" in lowered:
38
+ updated = deepcopy(workflow)
39
+ changed = []
40
+ for node in updated.nodes:
41
+ if node.data.type == "n8n-nodes-base.httpRequest":
42
+ options = node.data.parameters.setdefault("options", {})
43
+ if isinstance(options, dict):
44
+ options["retry"] = {"maxTries": 3, "waitBetweenTries": 1000}
45
+ changed.append(node.data.label)
46
+ return ChatResponse(
47
+ message=(
48
+ "I added bounded exponential retry settings to HTTP nodes."
49
+ if changed
50
+ else "There are no HTTP Request nodes to add retry behavior to."
51
+ ),
52
+ workflow=updated if changed else None,
53
+ actions=[f"Added retries to {name}" for name in changed],
54
+ )
55
+
56
+ if "explain" in lowered:
57
+ triggers = [
58
+ node.data.label for node in workflow.nodes if node.data.category == "trigger"
59
+ ]
60
+ return ChatResponse(
61
+ message=(
62
+ f"This workflow starts with {', '.join(triggers) or 'no trigger'}, "
63
+ f"then routes through {len(workflow.nodes) - len(triggers)} action nodes "
64
+ f"using {len(workflow.edges)} connections."
65
+ ),
66
+ actions=[
67
+ f"{node.data.label}: {node.data.subtitle or node.data.type}"
68
+ for node in workflow.nodes[:6]
69
+ ],
70
+ )
71
+
72
+ return ChatResponse(
73
+ message=(
74
+ "I inspected the current workflow. Ask me to validate it, explain a node, "
75
+ "add retries, or optimize the execution path."
76
+ ),
77
+ actions=[
78
+ "Validation and credential checks are available",
79
+ "Workflow changes are returned as a reviewable document",
80
+ ],
81
+ )
82
+
83
+
84
+ chat_service = WorkflowChatService()
app/services/collaboration.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import secrets
3
+ from datetime import UTC, datetime, timedelta
4
+ from uuid import uuid4
5
+
6
+ import httpx
7
+
8
+ from app.core.config import Settings
9
+ from app.models.workflow import (
10
+ ShareRequest,
11
+ ShareResponse,
12
+ SharedWorkflowResponse,
13
+ VersionSummary,
14
+ WorkflowComment,
15
+ WorkflowDocument,
16
+ )
17
+
18
+
19
+ _demo_shares: dict[str, SharedWorkflowResponse] = {}
20
+
21
+
22
+ class CollaborationRepository:
23
+ def __init__(self, settings: Settings):
24
+ self.settings = settings
25
+
26
+ @property
27
+ def configured(self) -> bool:
28
+ return bool(self.settings.supabase_url and self.settings.supabase_service_role_key)
29
+
30
+ @property
31
+ def base_url(self) -> str:
32
+ return f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
33
+
34
+ def _headers(self, prefer: str = "return=representation") -> dict[str, str]:
35
+ return {
36
+ "apikey": self.settings.supabase_service_role_key,
37
+ "Authorization": f"Bearer {self.settings.supabase_service_role_key}",
38
+ "Content-Type": "application/json",
39
+ "Prefer": prefer,
40
+ }
41
+
42
+ async def _workspace_access(
43
+ self, client: httpx.AsyncClient, user_id: str, *, edit: bool
44
+ ) -> set[str]:
45
+ response = await client.get(
46
+ f"{self.base_url}/workspace_members?select=workspace_id,role&user_id=eq.{user_id}",
47
+ headers=self._headers(),
48
+ )
49
+ response.raise_for_status()
50
+ allowed = {"owner", "admin", "editor"} if edit else {"owner", "admin", "editor", "viewer"}
51
+ return {row["workspace_id"] for row in response.json() if row["role"] in allowed}
52
+
53
+ async def _authorized_workflow(
54
+ self,
55
+ client: httpx.AsyncClient,
56
+ workflow_id: str,
57
+ user_id: str,
58
+ *,
59
+ edit: bool,
60
+ ) -> dict:
61
+ workspace_ids = await self._workspace_access(client, user_id, edit=edit)
62
+ response = await client.get(
63
+ f"{self.base_url}/workflows?select=id,workspace_id,definition&id=eq.{workflow_id}&limit=1",
64
+ headers=self._headers(),
65
+ )
66
+ response.raise_for_status()
67
+ rows = response.json()
68
+ if not rows or rows[0]["workspace_id"] not in workspace_ids:
69
+ raise ValueError("Workflow is not accessible by this user")
70
+ return rows[0]
71
+
72
+ async def authorize_deploy(self, workflow_id: str, user_id: str) -> bool:
73
+ if not self.configured:
74
+ return False
75
+ async with httpx.AsyncClient(timeout=10) as client:
76
+ response = await client.get(
77
+ (
78
+ f"{self.base_url}/workflows?select=workspace_id"
79
+ f"&id=eq.{workflow_id}&limit=1"
80
+ ),
81
+ headers=self._headers(),
82
+ )
83
+ response.raise_for_status()
84
+ workflows = response.json()
85
+ if not workflows:
86
+ raise ValueError("Workflow must be saved before deployment")
87
+ membership = await client.get(
88
+ (
89
+ f"{self.base_url}/workspace_members?select=role"
90
+ f"&workspace_id=eq.{workflows[0]['workspace_id']}"
91
+ f"&user_id=eq.{user_id}&limit=1"
92
+ ),
93
+ headers=self._headers(),
94
+ )
95
+ membership.raise_for_status()
96
+ rows = membership.json()
97
+ if not rows or rows[0]["role"] not in {"owner", "admin"}:
98
+ raise ValueError("Only workspace owners and admins can deploy workflows")
99
+ return True
100
+
101
+ async def create_share(self, request: ShareRequest, user_id: str) -> ShareResponse:
102
+ token = secrets.token_urlsafe(32)
103
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
104
+ expires_at = (
105
+ datetime.now(UTC) + timedelta(days=request.expires_in_days)
106
+ if request.expires_in_days
107
+ else None
108
+ )
109
+ share_id = str(uuid4())
110
+ if not self.configured:
111
+ if request.workflow is None:
112
+ raise ValueError("A workflow snapshot is required in local mode")
113
+ _demo_shares[token_hash] = SharedWorkflowResponse(
114
+ workflow=request.workflow,
115
+ permission=request.permission,
116
+ expires_at=expires_at,
117
+ )
118
+ else:
119
+ async with httpx.AsyncClient(timeout=10) as client:
120
+ await self._authorized_workflow(
121
+ client, request.workflow_id, user_id, edit=True
122
+ )
123
+ response = await client.post(
124
+ f"{self.base_url}/workflow_shares",
125
+ headers=self._headers(),
126
+ json={
127
+ "id": share_id,
128
+ "workflow_id": request.workflow_id,
129
+ "created_by": user_id,
130
+ "token_hash": token_hash,
131
+ "permission": request.permission,
132
+ "expires_at": expires_at.isoformat() if expires_at else None,
133
+ },
134
+ )
135
+ response.raise_for_status()
136
+ return ShareResponse(
137
+ id=share_id,
138
+ url=f"{self.settings.allowed_origins[0].rstrip('/')}/share/{token}",
139
+ permission=request.permission,
140
+ expires_at=expires_at,
141
+ )
142
+
143
+ async def shared_workflow(self, token: str) -> SharedWorkflowResponse:
144
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
145
+ if not self.configured:
146
+ shared = _demo_shares.get(token_hash)
147
+ if not shared:
148
+ raise ValueError("Share link was not found")
149
+ else:
150
+ async with httpx.AsyncClient(timeout=10) as client:
151
+ response = await client.get(
152
+ (
153
+ f"{self.base_url}/workflow_shares"
154
+ "?select=permission,expires_at,revoked_at,workflow:workflows(definition)"
155
+ f"&token_hash=eq.{token_hash}&limit=1"
156
+ ),
157
+ headers=self._headers(),
158
+ )
159
+ response.raise_for_status()
160
+ rows = response.json()
161
+ if not rows or rows[0]["revoked_at"]:
162
+ raise ValueError("Share link was not found")
163
+ row = rows[0]
164
+ shared = SharedWorkflowResponse(
165
+ workflow=WorkflowDocument.model_validate(row["workflow"]["definition"]),
166
+ permission=row["permission"],
167
+ expires_at=row["expires_at"],
168
+ )
169
+ if shared.expires_at and shared.expires_at < datetime.now(UTC):
170
+ raise ValueError("Share link has expired")
171
+ return shared
172
+
173
+ async def versions(self, workflow_id: str, user_id: str) -> list[VersionSummary]:
174
+ if not self.configured:
175
+ return []
176
+ async with httpx.AsyncClient(timeout=10) as client:
177
+ await self._authorized_workflow(client, workflow_id, user_id, edit=False)
178
+ response = await client.get(
179
+ (
180
+ f"{self.base_url}/workflow_versions"
181
+ "?select=id,version_number,change_summary,created_at,created_by"
182
+ f"&workflow_id=eq.{workflow_id}&order=version_number.desc&limit=100"
183
+ ),
184
+ headers=self._headers(),
185
+ )
186
+ response.raise_for_status()
187
+ return [
188
+ VersionSummary(
189
+ id=row["id"],
190
+ version=row["version_number"],
191
+ change_summary=row["change_summary"],
192
+ created_at=row["created_at"],
193
+ created_by=row["created_by"],
194
+ )
195
+ for row in response.json()
196
+ ]
197
+
198
+ async def restore(self, workflow_id: str, version: int, user_id: str) -> WorkflowDocument:
199
+ if not self.configured:
200
+ raise ValueError("Version restore requires Supabase")
201
+ async with httpx.AsyncClient(timeout=10) as client:
202
+ await self._authorized_workflow(client, workflow_id, user_id, edit=True)
203
+ response = await client.get(
204
+ (
205
+ f"{self.base_url}/workflow_versions?select=definition"
206
+ f"&workflow_id=eq.{workflow_id}&version_number=eq.{version}&limit=1"
207
+ ),
208
+ headers=self._headers(),
209
+ )
210
+ response.raise_for_status()
211
+ rows = response.json()
212
+ if not rows:
213
+ raise ValueError("Workflow version was not found")
214
+ definition = rows[0]["definition"]
215
+ definition["id"] = workflow_id
216
+ update = await client.patch(
217
+ f"{self.base_url}/workflows?id=eq.{workflow_id}",
218
+ headers=self._headers(),
219
+ json={"definition": definition, "name": definition["name"]},
220
+ )
221
+ update.raise_for_status()
222
+ history = await client.post(
223
+ f"{self.base_url}/workflow_versions",
224
+ headers=self._headers(),
225
+ json={
226
+ "workflow_id": workflow_id,
227
+ "created_by": user_id,
228
+ "definition": definition,
229
+ "change_summary": f"Restored version {version}",
230
+ },
231
+ )
232
+ history.raise_for_status()
233
+ return WorkflowDocument.model_validate(definition)
234
+
235
+ async def comments(self, workflow_id: str, user_id: str) -> list[WorkflowComment]:
236
+ if not self.configured:
237
+ return []
238
+ async with httpx.AsyncClient(timeout=10) as client:
239
+ await self._authorized_workflow(client, workflow_id, user_id, edit=False)
240
+ response = await client.get(
241
+ f"{self.base_url}/workflow_comments?select=*&workflow_id=eq.{workflow_id}&order=created_at.desc",
242
+ headers=self._headers(),
243
+ )
244
+ response.raise_for_status()
245
+ return [WorkflowComment(**row) for row in response.json()]
246
+
247
+ async def add_comment(
248
+ self, workflow_id: str, body: str, node_id: str | None, user_id: str
249
+ ) -> WorkflowComment:
250
+ if not self.configured:
251
+ return WorkflowComment(
252
+ id=str(uuid4()), workflow_id=workflow_id, user_id=user_id,
253
+ node_id=node_id, body=body, created_at=datetime.now(UTC).isoformat()
254
+ )
255
+ async with httpx.AsyncClient(timeout=10) as client:
256
+ await self._authorized_workflow(client, workflow_id, user_id, edit=False)
257
+ response = await client.post(
258
+ f"{self.base_url}/workflow_comments",
259
+ headers=self._headers(),
260
+ json={"workflow_id": workflow_id, "user_id": user_id, "node_id": node_id, "body": body},
261
+ )
262
+ response.raise_for_status()
263
+ return WorkflowComment(**response.json()[0])
app/services/deployment.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from urllib.parse import urlparse
2
+
3
+ import httpx
4
+
5
+ from app.core.config import Settings
6
+ from app.models.workflow import DeploymentResponse, WorkflowDocument
7
+ from app.services.adapters import adapters
8
+
9
+
10
+ async def deploy_to_n8n(
11
+ workflow: WorkflowDocument, activate: bool, settings: Settings, *, authorized: bool
12
+ ) -> DeploymentResponse:
13
+ if not authorized or not settings.n8n_base_url or not settings.n8n_api_key:
14
+ return DeploymentResponse(
15
+ status="preview",
16
+ message="Deployment preview is ready. Configure N8N_BASE_URL and N8N_API_KEY to deploy.",
17
+ )
18
+ parsed = urlparse(settings.n8n_base_url)
19
+ if parsed.scheme not in ({"https"} if settings.environment == "production" else {"http", "https"}):
20
+ raise ValueError("The configured n8n URL must use HTTPS in production")
21
+ payload = adapters.exporters["n8n"].dump(workflow)
22
+ payload.pop("active", None)
23
+ headers = {"X-N8N-API-KEY": settings.n8n_api_key, "Content-Type": "application/json"}
24
+ async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
25
+ response = await client.post(
26
+ f"{settings.n8n_base_url.rstrip('/')}/api/v1/workflows",
27
+ headers=headers,
28
+ json=payload,
29
+ )
30
+ response.raise_for_status()
31
+ remote = response.json()
32
+ remote_id = str(remote.get("id"))
33
+ if activate:
34
+ activation = await client.post(
35
+ f"{settings.n8n_base_url.rstrip('/')}/api/v1/workflows/{remote_id}/activate",
36
+ headers=headers,
37
+ )
38
+ activation.raise_for_status()
39
+ return DeploymentResponse(
40
+ status="deployed",
41
+ remote_workflow_id=remote_id,
42
+ message="Workflow deployed to the configured n8n instance.",
43
+ )
app/services/generator.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from dataclasses import dataclass
3
+ from typing import Protocol
4
+
5
+ from app.models.workflow import (
6
+ CredentialPlaceholder,
7
+ GenerateWorkflowResponse,
8
+ Position,
9
+ WorkflowDocument,
10
+ WorkflowEdge,
11
+ WorkflowMeta,
12
+ WorkflowNode,
13
+ WorkflowNodeData,
14
+ )
15
+
16
+
17
+ class WorkflowProvider(Protocol):
18
+ async def generate(self, prompt: str) -> GenerateWorkflowResponse: ...
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class PlannedNode:
23
+ key: str
24
+ label: str
25
+ node_type: str
26
+ category: str
27
+ subtitle: str
28
+ parameters: dict
29
+ credentials: list[str]
30
+
31
+
32
+ def _credential_map(types: list[str], label: str) -> dict[str, CredentialPlaceholder] | None:
33
+ if not types:
34
+ return None
35
+ return {
36
+ credential_type: CredentialPlaceholder(
37
+ name=f"Connect {label}", type=credential_type
38
+ )
39
+ for credential_type in types
40
+ }
41
+
42
+
43
+ class RuleBasedWorkflowProvider:
44
+ """CPU-friendly baseline provider used as a fallback and in tests."""
45
+
46
+ async def generate(self, prompt: str) -> GenerateWorkflowResponse:
47
+ text = prompt.lower()
48
+ plan: list[PlannedNode] = []
49
+
50
+ if "gmail" in text or "email" in text:
51
+ plan.append(
52
+ PlannedNode(
53
+ "gmail-trigger",
54
+ "Gmail Trigger",
55
+ "n8n-nodes-base.gmailTrigger",
56
+ "trigger",
57
+ "New matching email",
58
+ {"filters": {"hasAttachment": "attachment" in text}},
59
+ ["gmailOAuth2"],
60
+ )
61
+ )
62
+ elif any(term in text for term in ("schedule", "daily", "hourly", "weekly")):
63
+ plan.append(
64
+ PlannedNode(
65
+ "schedule-trigger",
66
+ "Schedule Trigger",
67
+ "n8n-nodes-base.scheduleTrigger",
68
+ "trigger",
69
+ "Every hour",
70
+ {"rule": {"interval": [{"field": "hours", "hoursInterval": 1}]}},
71
+ [],
72
+ )
73
+ )
74
+ else:
75
+ plan.append(
76
+ PlannedNode(
77
+ "webhook",
78
+ "Webhook",
79
+ "n8n-nodes-base.webhook",
80
+ "trigger",
81
+ "POST /flowforge-webhook",
82
+ {"httpMethod": "POST", "path": "flowforge-webhook"},
83
+ [],
84
+ )
85
+ )
86
+
87
+ if any(term in text for term in ("extract", "classify", "summar", "invoice", " ai ")):
88
+ plan.append(
89
+ PlannedNode(
90
+ "ai-extract",
91
+ "Extract Structured Data",
92
+ "@n8n/n8n-nodes-langchain.informationExtractor",
93
+ "ai",
94
+ "AI structured output",
95
+ {
96
+ "text": "={{ $binary.data || $json.text || $json.body }}",
97
+ "schemaType": "manual",
98
+ "inputSchema": (
99
+ '{"invoice_number":"string","vendor":"string",'
100
+ '"amount":"number","due_date":"string"}'
101
+ if "invoice" in text
102
+ else '{"result":"string","confidence":"number"}'
103
+ ),
104
+ },
105
+ ["openAiApi"],
106
+ )
107
+ )
108
+
109
+ if "http" in text or " api" in text:
110
+ plan.append(
111
+ PlannedNode(
112
+ "http-request",
113
+ "HTTP Request",
114
+ "n8n-nodes-base.httpRequest",
115
+ "core",
116
+ "Call external API",
117
+ {
118
+ "method": "GET",
119
+ "url": "https://api.example.com/resource",
120
+ "options": {"timeout": 30000},
121
+ },
122
+ [],
123
+ )
124
+ )
125
+
126
+ if "supabase" in text:
127
+ plan.append(
128
+ PlannedNode(
129
+ "supabase",
130
+ "Store in Supabase",
131
+ "n8n-nodes-base.supabase",
132
+ "database",
133
+ "Insert record",
134
+ {
135
+ "operation": "create",
136
+ "tableId": "invoices" if "invoice" in text else "records",
137
+ "fieldsUi": {"fieldValues": []},
138
+ },
139
+ ["supabaseApi"],
140
+ )
141
+ )
142
+ elif "postgres" in text or "database" in text:
143
+ plan.append(
144
+ PlannedNode(
145
+ "postgres",
146
+ "Save to Postgres",
147
+ "n8n-nodes-base.postgres",
148
+ "database",
149
+ "Insert record",
150
+ {
151
+ "operation": "executeQuery",
152
+ "query": "INSERT INTO records (payload) VALUES ($1)",
153
+ "options": {"queryReplacement": "={{ [$json] }}"},
154
+ },
155
+ ["postgres"],
156
+ )
157
+ )
158
+
159
+ if "slack" in text or "notification" in text or "notify" in text:
160
+ plan.append(
161
+ PlannedNode(
162
+ "slack",
163
+ "Send Slack Notification",
164
+ "n8n-nodes-base.slack",
165
+ "communication",
166
+ "#automation",
167
+ {
168
+ "resource": "message",
169
+ "operation": "send",
170
+ "channel": "#automation",
171
+ "text": "=Workflow completed for {{ $json.invoice_number || $json.id }}",
172
+ },
173
+ ["slackOAuth2Api"],
174
+ )
175
+ )
176
+
177
+ if len(plan) == 1:
178
+ plan.append(
179
+ PlannedNode(
180
+ "edit-fields",
181
+ "Prepare Output",
182
+ "n8n-nodes-base.set",
183
+ "core",
184
+ "Normalize response",
185
+ {
186
+ "assignments": {
187
+ "assignments": [
188
+ {
189
+ "name": "status",
190
+ "value": "completed",
191
+ "type": "string",
192
+ }
193
+ ]
194
+ }
195
+ },
196
+ [],
197
+ )
198
+ )
199
+
200
+ nodes = [
201
+ WorkflowNode(
202
+ id=item.key,
203
+ position=Position(x=80 + index * 315, y=190 + (index % 2) * 45),
204
+ data=WorkflowNodeData(
205
+ label=item.label,
206
+ type=item.node_type,
207
+ typeVersion=1,
208
+ category=item.category, # type: ignore[arg-type]
209
+ subtitle=item.subtitle,
210
+ parameters=item.parameters,
211
+ credentials=_credential_map(item.credentials, item.label),
212
+ ),
213
+ )
214
+ for index, item in enumerate(plan)
215
+ ]
216
+ edges = [
217
+ WorkflowEdge(
218
+ id=f"{source.key}-{target.key}",
219
+ source=source.key,
220
+ target=target.key,
221
+ animated=index == 0,
222
+ )
223
+ for index, (source, target) in enumerate(zip(plan, plan[1:]))
224
+ ]
225
+
226
+ title_words = re.findall(r"[a-zA-Z0-9]+", prompt)[:7]
227
+ title = " ".join(title_words).strip().capitalize() or "Generated workflow"
228
+ if len(title) > 62:
229
+ title = f"{title[:59]}..."
230
+
231
+ workflow = WorkflowDocument(
232
+ name=title,
233
+ nodes=nodes,
234
+ edges=edges,
235
+ settings={
236
+ "executionOrder": "v1",
237
+ "saveManualExecutions": True,
238
+ "saveExecutionProgress": True,
239
+ "errorWorkflow": "",
240
+ "timezone": "UTC",
241
+ },
242
+ meta=WorkflowMeta(
243
+ description=prompt[:500],
244
+ generatedBy="FlowForge rule-based provider",
245
+ version=1,
246
+ tags=["AI generated"],
247
+ ),
248
+ )
249
+ return GenerateWorkflowResponse(
250
+ workflow=workflow,
251
+ explanation=f"Created a {len(nodes)}-node workflow with a trigger and connected actions.",
252
+ warnings=[
253
+ "Replace credential placeholders before activating the workflow.",
254
+ "Review generated expressions with representative execution data.",
255
+ ],
256
+ )
257
+
258
+
259
+ class ProviderRegistry:
260
+ def __init__(self) -> None:
261
+ self._providers: dict[str, WorkflowProvider] = {}
262
+
263
+ def register(self, name: str, provider: WorkflowProvider) -> None:
264
+ self._providers[name] = provider
265
+
266
+ def get(self, name: str) -> WorkflowProvider:
267
+ return self._providers.get(name, self._providers["mock"])
268
+
269
+
270
+ providers = ProviderRegistry()
271
+ providers.register("mock", RuleBasedWorkflowProvider())
app/services/intelligence.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from copy import deepcopy
3
+ from typing import Any
4
+
5
+ from app.models.workflow import (
6
+ ContractResponse,
7
+ DependencyImpactResponse,
8
+ DocumentationResponse,
9
+ EnvironmentPromotionResponse,
10
+ IntentDriftResponse,
11
+ LineageField,
12
+ LineageResponse,
13
+ QualityResponse,
14
+ ReleasePlanRequest,
15
+ ReleasePlanResponse,
16
+ RoiResponse,
17
+ SelfHealResponse,
18
+ ValidationIssue,
19
+ WebhookInspectResponse,
20
+ WorkflowDocument,
21
+ WorkflowPackageRequest,
22
+ WorkflowPackageResponse,
23
+ )
24
+ from app.services.operations import estimate_cost
25
+ from app.services.optimizer import optimizer
26
+ from app.services.validation import validator
27
+
28
+
29
+ FIELD_REF = re.compile(r"\$json\.([A-Za-z_][\w.]*)")
30
+ ENV_REF = re.compile(r"\$\{ENV\.([A-Z][A-Z0-9_]*)\}")
31
+ WORD = re.compile(r"[a-z][a-z0-9_-]{2,}")
32
+ SECRET_NAMES = {"password", "secret", "token", "api_key", "apikey", "authorization"}
33
+ PERSONAL_NAMES = {"email", "phone", "address", "name", "ssn", "dob"}
34
+ FINANCIAL_NAMES = {"amount", "invoice", "card", "iban", "account", "price"}
35
+ STOP_WORDS = {"that", "with", "from", "this", "then", "into", "workflow", "create", "build"}
36
+
37
+
38
+ def _walk(value: Any, prefix: str = ""):
39
+ if isinstance(value, dict):
40
+ for key, nested in value.items():
41
+ path = f"{prefix}.{key}" if prefix else str(key)
42
+ yield path, nested
43
+ yield from _walk(nested, path)
44
+ elif isinstance(value, list):
45
+ for index, nested in enumerate(value):
46
+ yield from _walk(nested, f"{prefix}[{index}]")
47
+
48
+
49
+ def _classification(field: str) -> str:
50
+ parts = {part.lower() for part in re.split(r"[.\[\]_-]+", field) if part}
51
+ if parts & SECRET_NAMES:
52
+ return "secret"
53
+ if parts & FINANCIAL_NAMES:
54
+ return "financial"
55
+ if parts & PERSONAL_NAMES:
56
+ return "personal"
57
+ return "internal"
58
+
59
+
60
+ def lineage(workflow: WorkflowDocument) -> LineageResponse:
61
+ dependencies: dict[str, list[str]] = {node.id: [] for node in workflow.nodes}
62
+ fields: dict[str, dict[str, set[str] | str]] = {}
63
+ labels = {node.id: node.data.label for node in workflow.nodes}
64
+ for edge in workflow.edges:
65
+ if edge.target in dependencies and edge.source in labels:
66
+ dependencies[edge.target].append(labels[edge.source])
67
+ for node in workflow.nodes:
68
+ for path, value in _walk(node.data.parameters):
69
+ candidates = {path}
70
+ if isinstance(value, str):
71
+ candidates.update(FIELD_REF.findall(value))
72
+ for field in candidates:
73
+ record = fields.setdefault(
74
+ field,
75
+ {"sources": set(), "consumers": set(), "classification": _classification(field)},
76
+ )
77
+ if field == path:
78
+ record["sources"].add(node.data.label) # type: ignore[union-attr]
79
+ else:
80
+ record["consumers"].add(node.data.label) # type: ignore[union-attr]
81
+ result = [
82
+ LineageField(
83
+ field=field,
84
+ source_nodes=sorted(record["sources"]), # type: ignore[arg-type]
85
+ consumer_nodes=sorted(record["consumers"]), # type: ignore[arg-type]
86
+ classification=record["classification"], # type: ignore[arg-type]
87
+ )
88
+ for field, record in sorted(fields.items())
89
+ ]
90
+ return LineageResponse(
91
+ fields=result,
92
+ node_dependencies=dependencies,
93
+ sensitive_paths=[field.field for field in result if field.classification in {"secret", "personal", "financial"}],
94
+ )
95
+
96
+
97
+ def _type_name(value: Any) -> str:
98
+ if value is None:
99
+ return "null"
100
+ if isinstance(value, bool):
101
+ return "boolean"
102
+ if isinstance(value, (int, float)):
103
+ return "number"
104
+ if isinstance(value, dict):
105
+ return "object"
106
+ if isinstance(value, list):
107
+ return "array"
108
+ return "string"
109
+
110
+
111
+ def contract_check(sample: dict[str, Any], expected: dict[str, str]) -> ContractResponse:
112
+ inferred = {path: _type_name(value) for path, value in _walk(sample) if not isinstance(value, (dict, list))}
113
+ violations = []
114
+ for path, expected_type in expected.items():
115
+ actual = inferred.get(path)
116
+ if actual is None:
117
+ violations.append(f"Missing required field: {path}")
118
+ elif actual != expected_type:
119
+ violations.append(f"{path} expected {expected_type}, received {actual}")
120
+ return ContractResponse(valid=not violations, inferred_schema=inferred, violations=violations)
121
+
122
+
123
+ def quality(workflow: WorkflowDocument) -> QualityResponse:
124
+ validation = validator.validate(workflow)
125
+ findings = list(validation.issues)
126
+ lineage_result = lineage(workflow)
127
+ code_nodes = [node for node in workflow.nodes if node.data.type == "n8n-nodes-base.code"]
128
+ if code_nodes:
129
+ findings.append(ValidationIssue(code="review_code", severity="warning", message="Code nodes require review and sandboxed testing."))
130
+ if lineage_result.sensitive_paths:
131
+ findings.append(ValidationIssue(code="sensitive_data", severity="warning", message="Sensitive fields require destination and retention review."))
132
+ retry_nodes = sum("retry" in str(node.data.parameters).lower() for node in workflow.nodes)
133
+ scores = {
134
+ "reliability": max(0, validation.score - (10 if retry_nodes == 0 else 0)),
135
+ "maintainability": max(0, 100 - len(code_nodes) * 12 - max(0, len(workflow.nodes) - 30)),
136
+ "security": max(0, 100 - len(lineage_result.sensitive_paths) * 4 - len(code_nodes) * 8),
137
+ "cost": max(0, 100 - sum("langchain" in node.data.type for node in workflow.nodes) * 7),
138
+ "observability": 90 if workflow.settings.get("saveExecutionProgress") else 55,
139
+ }
140
+ return QualityResponse(overall=round(sum(scores.values()) / len(scores)), scores=scores, findings=findings)
141
+
142
+
143
+ def intent_drift(workflow: WorkflowDocument, requirement: str) -> IntentDriftResponse:
144
+ terms = {term for term in WORD.findall(requirement.lower()) if term not in STOP_WORDS}
145
+ workflow_text = " ".join(
146
+ f"{node.data.label} {node.data.type} {node.data.subtitle or ''}" for node in workflow.nodes
147
+ ).lower()
148
+ covered = sorted(term for term in terms if term in workflow_text)
149
+ missing = sorted(terms - set(covered))
150
+ return IntentDriftResponse(
151
+ alignment_score=round(len(covered) / max(1, len(terms)) * 100),
152
+ covered_terms=covered,
153
+ missing_terms=missing,
154
+ )
155
+
156
+
157
+ def promote(workflow: WorkflowDocument, environment: str, values: dict[str, Any]) -> EnvironmentPromotionResponse:
158
+ promoted = deepcopy(workflow)
159
+ replacements = 0
160
+ unresolved: set[str] = set()
161
+
162
+ def replace(value: Any) -> Any:
163
+ nonlocal replacements
164
+ if isinstance(value, str):
165
+ def substitute(match: re.Match[str]) -> str:
166
+ nonlocal replacements
167
+ key = match.group(1)
168
+ if key not in values:
169
+ unresolved.add(key)
170
+ return match.group(0)
171
+ replacements += 1
172
+ return str(values[key])
173
+ return ENV_REF.sub(substitute, value)
174
+ if isinstance(value, dict):
175
+ return {key: replace(nested) for key, nested in value.items()}
176
+ if isinstance(value, list):
177
+ return [replace(nested) for nested in value]
178
+ return value
179
+
180
+ for node in promoted.nodes:
181
+ node.data.parameters = replace(node.data.parameters)
182
+ promoted.meta.model_extra["environment"] = environment
183
+ return EnvironmentPromotionResponse(
184
+ workflow=promoted, environment=environment, replacements=replacements, unresolved=sorted(unresolved)
185
+ )
186
+
187
+
188
+ def release_plan(request: ReleasePlanRequest) -> ReleasePlanResponse:
189
+ validation = validator.validate(request.workflow)
190
+ blocked = not validation.valid
191
+ strategy_steps = {
192
+ "shadow": ["Mirror sanitized input to the candidate version", "Suppress side-effect nodes", "Compare schemas, outputs, and latency"],
193
+ "canary": [f"Route {request.traffic_percentage}% of eligible traffic to the candidate", "Monitor success rate and latency", "Increase traffic only after approval"],
194
+ "synthetic": ["Schedule representative test inputs", "Verify credentials and contracts", "Alert on consecutive failures"],
195
+ }
196
+ return ReleasePlanResponse(
197
+ strategy=request.strategy,
198
+ status="blocked" if blocked else "draft",
199
+ steps=strategy_steps[request.strategy],
200
+ rollback_conditions=[
201
+ f"Error rate exceeds {request.max_error_rate:.1%}",
202
+ f"Success rate drops below {request.success_threshold:.1%}",
203
+ "Output contract or sensitive-data policy fails",
204
+ ],
205
+ warnings=[issue.message for issue in validation.issues if issue.severity == "error"],
206
+ )
207
+
208
+
209
+ def package_workflow(request: WorkflowPackageRequest) -> WorkflowPackageResponse:
210
+ return WorkflowPackageResponse(
211
+ manifest={
212
+ "format": "flowforge.workflow-package/v1",
213
+ "name": request.workflow.name,
214
+ "workflowVersion": request.workflow.meta.version or 1,
215
+ "n8nCompatible": True,
216
+ "contents": ["workflow", "tests", "contracts", "environments"],
217
+ },
218
+ workflow=request.workflow,
219
+ tests=request.tests,
220
+ contracts=request.contracts,
221
+ environments=request.environments,
222
+ )
223
+
224
+
225
+ def documentation(workflow: WorkflowDocument) -> DocumentationResponse:
226
+ nodes = "\n".join(f"- **{node.data.label}** (`{node.data.type}`): {node.data.subtitle or 'Configured action'}" for node in workflow.nodes)
227
+ connections = "\n".join(
228
+ f"- {next((n.data.label for n in workflow.nodes if n.id == edge.source), edge.source)} -> "
229
+ f"{next((n.data.label for n in workflow.nodes if n.id == edge.target), edge.target)}"
230
+ for edge in workflow.edges
231
+ )
232
+ return DocumentationResponse(markdown=f"# {workflow.name}\n\n{workflow.meta.description or 'n8n workflow'}\n\n## Nodes\n\n{nodes}\n\n## Connections\n\n{connections or '- None'}\n")
233
+
234
+
235
+ def roi(workflow: WorkflowDocument, executions: int, minutes_saved: float, hourly_rate: float, sla_minutes: float) -> RoiResponse:
236
+ hours = executions * minutes_saved / 60
237
+ cost = estimate_cost(workflow, executions).estimated_monthly_usd
238
+ estimated_duration = max(1, len(workflow.nodes) * 250)
239
+ return RoiResponse(
240
+ hours_saved=round(hours, 2), labor_value_usd=round(hours * hourly_rate, 2),
241
+ estimated_operating_cost_usd=cost, net_value_usd=round(hours * hourly_rate - cost, 2),
242
+ estimated_duration_ms=estimated_duration,
243
+ sla_headroom_percent=round(max(0, 1 - estimated_duration / (sla_minutes * 60_000)) * 100, 2),
244
+ )
245
+
246
+
247
+ def inspect_webhook(payload: dict[str, Any], redact: bool) -> WebhookInspectResponse:
248
+ result = deepcopy(payload)
249
+ schema = {path: _type_name(value) for path, value in _walk(payload) if not isinstance(value, (dict, list))}
250
+ redacted = []
251
+ if redact:
252
+ def scrub(value: Any, prefix: str = "") -> None:
253
+ if not isinstance(value, dict):
254
+ return
255
+ for key, nested in value.items():
256
+ path = f"{prefix}.{key}" if prefix else key
257
+ if _classification(path) in {"secret", "personal", "financial"}:
258
+ value[key] = "[REDACTED]"
259
+ redacted.append(path)
260
+ else:
261
+ scrub(nested, path)
262
+ scrub(result)
263
+ return WebhookInspectResponse(payload=result, schema_map=schema, redacted_fields=redacted)
264
+
265
+
266
+ def dependency_impact(workflow: WorkflowDocument, dependency: str) -> DependencyImpactResponse:
267
+ query = dependency.lower()
268
+ affected = [node for node in workflow.nodes if query in f"{node.data.type} {node.data.label} {node.data.parameters}".lower()]
269
+ affected_ids = {node.id for node in affected}
270
+ downstream_ids: set[str] = set()
271
+ frontier = list(affected_ids)
272
+ while frontier:
273
+ source = frontier.pop()
274
+ for edge in workflow.edges:
275
+ if edge.source == source and edge.target not in downstream_ids:
276
+ downstream_ids.add(edge.target)
277
+ frontier.append(edge.target)
278
+ labels = {node.id: node.data.label for node in workflow.nodes}
279
+ count = len(affected_ids | downstream_ids)
280
+ severity = "none" if count == 0 else "low" if count == 1 else "medium" if count < 5 else "high"
281
+ return DependencyImpactResponse(
282
+ affected_nodes=[node.data.label for node in affected],
283
+ downstream_nodes=[labels[node_id] for node_id in downstream_ids if node_id in labels],
284
+ severity=severity,
285
+ )
286
+
287
+
288
+ def self_heal(workflow: WorkflowDocument, errors: list[str]) -> SelfHealResponse:
289
+ before = quality(workflow)
290
+ optimized = optimizer.optimize(workflow)
291
+ after = quality(optimized.workflow)
292
+ changes = [suggestion.title for suggestion in optimized.suggestions]
293
+ if errors:
294
+ changes.append("Attached execution errors as approval evidence")
295
+ return SelfHealResponse(
296
+ proposed_workflow=optimized.workflow,
297
+ changes=changes,
298
+ quality_before=before.overall,
299
+ quality_after=after.overall,
300
+ )
app/services/operations.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from time import perf_counter
4
+ from typing import Any
5
+
6
+ from app.models.workflow import (
7
+ CostEstimate,
8
+ NodeRunResult,
9
+ SimulationResponse,
10
+ TestAssertion,
11
+ TestCaseResult,
12
+ WorkflowDiff,
13
+ WorkflowDocument,
14
+ WorkflowTestCase,
15
+ )
16
+
17
+
18
+ EXPRESSION = re.compile(r"^=\{\{\s*\$json(?:\.([\w.]+))?\s*\}\}$")
19
+
20
+
21
+ def _get_path(value: Any, path: str) -> Any:
22
+ current = value
23
+ for part in path.removeprefix("$.").split("."):
24
+ if not part:
25
+ continue
26
+ if isinstance(current, dict) and part in current:
27
+ current = current[part]
28
+ else:
29
+ return None
30
+ return current
31
+
32
+
33
+ def _resolve(value: Any, item: dict[str, Any]) -> Any:
34
+ if isinstance(value, str):
35
+ match = EXPRESSION.match(value)
36
+ if match:
37
+ return _get_path(item, match.group(1) or "")
38
+ return value
39
+ if isinstance(value, list):
40
+ return [_resolve(entry, item) for entry in value]
41
+ if isinstance(value, dict):
42
+ return {key: _resolve(entry, item) for key, entry in value.items()}
43
+ return value
44
+
45
+
46
+ def _ordered_nodes(workflow: WorkflowDocument):
47
+ nodes = {node.id: node for node in workflow.nodes}
48
+ indegree = {node_id: 0 for node_id in nodes}
49
+ outgoing: dict[str, list[str]] = {node_id: [] for node_id in nodes}
50
+ for edge in workflow.edges:
51
+ if edge.source in nodes and edge.target in nodes:
52
+ outgoing[edge.source].append(edge.target)
53
+ indegree[edge.target] += 1
54
+ queue = [node_id for node_id, degree in indegree.items() if degree == 0]
55
+ ordered = []
56
+ while queue:
57
+ node_id = queue.pop(0)
58
+ ordered.append(nodes[node_id])
59
+ for target in outgoing[node_id]:
60
+ indegree[target] -= 1
61
+ if indegree[target] == 0:
62
+ queue.append(target)
63
+ if len(ordered) != len(nodes):
64
+ raise ValueError("Workflow contains a cycle and cannot be simulated")
65
+ return ordered
66
+
67
+
68
+ def simulate(workflow: WorkflowDocument, input_data: dict[str, Any]) -> SimulationResponse:
69
+ started = perf_counter()
70
+ item = dict(input_data)
71
+ trace: list[NodeRunResult] = []
72
+ warnings = [
73
+ "Simulation does not call external services or execute Code nodes; outputs are deterministic previews."
74
+ ]
75
+ try:
76
+ ordered = _ordered_nodes(workflow)
77
+ except ValueError as exc:
78
+ return SimulationResponse(
79
+ status="error", duration_ms=0, trace=[], output_data=item, warnings=[str(exc)]
80
+ )
81
+
82
+ for node in ordered:
83
+ node_started = perf_counter()
84
+ incoming = dict(item)
85
+ parameters = _resolve(node.data.parameters, incoming)
86
+ if node.data.disabled:
87
+ status = "skipped"
88
+ output = incoming
89
+ elif node.data.type == "n8n-nodes-base.set":
90
+ output = {**incoming, "parameters": parameters}
91
+ status = "success"
92
+ elif node.data.type == "n8n-nodes-base.code":
93
+ output = {**incoming, "_simulation": "Code execution skipped"}
94
+ status = "skipped"
95
+ else:
96
+ output = {
97
+ **incoming,
98
+ "_lastNode": node.data.label,
99
+ "_parameters": parameters,
100
+ }
101
+ status = "success"
102
+ duration = max(1, round((perf_counter() - node_started) * 1000))
103
+ trace.append(
104
+ NodeRunResult(
105
+ node_id=node.id,
106
+ node_name=node.data.label,
107
+ status=status,
108
+ duration_ms=duration,
109
+ input_data=incoming,
110
+ output_data=output,
111
+ )
112
+ )
113
+ item = output
114
+
115
+ return SimulationResponse(
116
+ status="success",
117
+ duration_ms=max(1, round((perf_counter() - started) * 1000)),
118
+ trace=trace,
119
+ output_data=item,
120
+ warnings=warnings,
121
+ )
122
+
123
+
124
+ def replay_from_node(
125
+ workflow: WorkflowDocument, node_id: str, input_data: dict[str, Any]
126
+ ) -> SimulationResponse:
127
+ ids = {node.id for node in workflow.nodes}
128
+ if node_id not in ids:
129
+ return SimulationResponse(
130
+ status="error",
131
+ duration_ms=0,
132
+ trace=[],
133
+ output_data=input_data,
134
+ warnings=["Replay node was not found"],
135
+ )
136
+ reachable = {node_id}
137
+ frontier = [node_id]
138
+ while frontier:
139
+ source = frontier.pop()
140
+ for edge in workflow.edges:
141
+ if edge.source == source and edge.target not in reachable:
142
+ reachable.add(edge.target)
143
+ frontier.append(edge.target)
144
+ replay = workflow.model_copy(deep=True)
145
+ replay.nodes = [node for node in replay.nodes if node.id in reachable]
146
+ replay.edges = [
147
+ edge for edge in replay.edges if edge.source in reachable and edge.target in reachable
148
+ ]
149
+ result = simulate(replay, input_data)
150
+ result.warnings.append("Replay starts from captured input and suppresses real external side effects.")
151
+ return result
152
+
153
+
154
+ def _assert(assertion: TestAssertion, output: dict[str, Any]) -> str | None:
155
+ actual = _get_path(output, assertion.path)
156
+ if assertion.operator == "exists":
157
+ passed = actual is not None
158
+ elif assertion.operator == "equals":
159
+ passed = actual == assertion.expected
160
+ elif assertion.operator == "not_equals":
161
+ passed = actual != assertion.expected
162
+ else:
163
+ passed = assertion.expected in actual if isinstance(actual, (str, list, dict)) else False
164
+ if passed:
165
+ return None
166
+ return f"{assertion.path} {assertion.operator} {json.dumps(assertion.expected)} (actual: {json.dumps(actual)})"
167
+
168
+
169
+ def run_test(workflow: WorkflowDocument, case: WorkflowTestCase) -> TestCaseResult:
170
+ result = simulate(workflow, case.input_data)
171
+ failures = [_assert(assertion, result.output_data) for assertion in case.assertions]
172
+ failures = [failure for failure in failures if failure]
173
+ if result.status == "error":
174
+ failures.extend(result.warnings)
175
+ return TestCaseResult(
176
+ name=case.name,
177
+ passed=not failures,
178
+ failures=failures,
179
+ duration_ms=result.duration_ms,
180
+ )
181
+
182
+
183
+ def estimate_cost(workflow: WorkflowDocument, executions: int) -> CostEstimate:
184
+ ai_nodes = sum("langchain" in node.data.type.lower() or "openai" in node.data.type.lower() for node in workflow.nodes)
185
+ api_nodes = sum(
186
+ node.data.type in {"n8n-nodes-base.httpRequest", "n8n-nodes-base.slack", "n8n-nodes-base.gmail"}
187
+ for node in workflow.nodes
188
+ )
189
+ ai_tokens = executions * ai_nodes * 1500
190
+ api_calls = executions * api_nodes
191
+ warnings = []
192
+ if executions > 100_000 and api_nodes:
193
+ warnings.append("High request volume: add batching, backoff, and provider-specific concurrency limits.")
194
+ return CostEstimate(
195
+ executions_per_month=executions,
196
+ estimated_api_calls=api_calls,
197
+ estimated_ai_tokens=ai_tokens,
198
+ estimated_monthly_usd=round(ai_tokens / 1_000_000 * 0.75, 2),
199
+ assumptions=[
200
+ "AI nodes average 1,500 tokens per execution.",
201
+ "External service nodes make one request per execution.",
202
+ "Estimate excludes n8n hosting and third-party subscription fees.",
203
+ ],
204
+ rate_limit_warnings=warnings,
205
+ )
206
+
207
+
208
+ def diff_workflows(before: WorkflowDocument, after: WorkflowDocument) -> WorkflowDiff:
209
+ old = {node.id: node for node in before.nodes}
210
+ new = {node.id: node for node in after.nodes}
211
+ shared = old.keys() & new.keys()
212
+ old_edges = {(edge.source, edge.target, edge.sourceHandle, edge.targetHandle) for edge in before.edges}
213
+ new_edges = {(edge.source, edge.target, edge.sourceHandle, edge.targetHandle) for edge in after.edges}
214
+ return WorkflowDiff(
215
+ added_nodes=[new[node_id].data.label for node_id in new.keys() - old.keys()],
216
+ removed_nodes=[old[node_id].data.label for node_id in old.keys() - new.keys()],
217
+ modified_nodes=[
218
+ new[node_id].data.label
219
+ for node_id in shared
220
+ if old[node_id].data.model_dump(exclude={"issues"})
221
+ != new[node_id].data.model_dump(exclude={"issues"})
222
+ ],
223
+ moved_nodes=[
224
+ new[node_id].data.label
225
+ for node_id in shared
226
+ if old[node_id].position != new[node_id].position
227
+ ],
228
+ added_edges=len(new_edges - old_edges),
229
+ removed_edges=len(old_edges - new_edges),
230
+ )
app/services/optimizer.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from copy import deepcopy
2
+
3
+ from app.models.workflow import (
4
+ OptimizationResponse,
5
+ OptimizationSuggestion,
6
+ WorkflowDocument,
7
+ )
8
+
9
+
10
+ class WorkflowOptimizer:
11
+ def optimize(self, workflow: WorkflowDocument) -> OptimizationResponse:
12
+ optimized = deepcopy(workflow)
13
+ suggestions: list[OptimizationSuggestion] = []
14
+
15
+ http_nodes = [
16
+ node for node in optimized.nodes if node.data.type == "n8n-nodes-base.httpRequest"
17
+ ]
18
+ for node in http_nodes:
19
+ options = node.data.parameters.setdefault("options", {})
20
+ if isinstance(options, dict) and "retry" not in options:
21
+ options.update({"retry": {"maxTries": 3, "waitBetweenTries": 1000}})
22
+ suggestions.append(
23
+ OptimizationSuggestion(
24
+ title="Add API retries",
25
+ description=f"Added bounded retry behavior to {node.data.label}.",
26
+ impact="high",
27
+ nodeIds=[node.id],
28
+ )
29
+ )
30
+
31
+ if not optimized.settings.get("saveExecutionProgress"):
32
+ optimized.settings["saveExecutionProgress"] = True
33
+ suggestions.append(
34
+ OptimizationSuggestion(
35
+ title="Enable execution recovery",
36
+ description="Save execution progress so long-running workflows can recover.",
37
+ impact="medium",
38
+ )
39
+ )
40
+
41
+ linear_pairs = []
42
+ for edge in optimized.edges:
43
+ source = next((node for node in optimized.nodes if node.id == edge.source), None)
44
+ target = next((node for node in optimized.nodes if node.id == edge.target), None)
45
+ if source and target and source.data.type == target.data.type == "n8n-nodes-base.set":
46
+ linear_pairs.append((source, target))
47
+ if linear_pairs:
48
+ suggestions.append(
49
+ OptimizationSuggestion(
50
+ title="Combine adjacent Edit Fields nodes",
51
+ description="Adjacent field transformations can be handled by one node.",
52
+ impact="medium",
53
+ nodeIds=[node.id for pair in linear_pairs for node in pair],
54
+ )
55
+ )
56
+
57
+ branches = {}
58
+ for edge in optimized.edges:
59
+ branches[edge.source] = branches.get(edge.source, 0) + 1
60
+ parallel = [node_id for node_id, count in branches.items() if count > 1]
61
+ if parallel:
62
+ suggestions.append(
63
+ OptimizationSuggestion(
64
+ title="Review parallel branches",
65
+ description="Independent branches can execute concurrently; merge only when required.",
66
+ impact="medium",
67
+ nodeIds=parallel,
68
+ )
69
+ )
70
+
71
+ if not suggestions:
72
+ suggestions.append(
73
+ OptimizationSuggestion(
74
+ title="Workflow is already compact",
75
+ description="No deterministic optimization could be applied safely.",
76
+ impact="low",
77
+ )
78
+ )
79
+
80
+ return OptimizationResponse(workflow=optimized, suggestions=suggestions)
81
+
82
+
83
+ optimizer = WorkflowOptimizer()
app/services/remote.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ipaddress
2
+ import socket
3
+ from urllib.parse import urlparse
4
+
5
+ import httpx
6
+
7
+ MAX_IMPORT_BYTES = 5_000_000
8
+ ALLOWED_GITHUB_HOSTS = {"github.com", "raw.githubusercontent.com"}
9
+
10
+
11
+ def _is_public_host(hostname: str) -> bool:
12
+ try:
13
+ addresses = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
14
+ except socket.gaierror:
15
+ return False
16
+ for address in addresses:
17
+ ip = ipaddress.ip_address(address[4][0])
18
+ if (
19
+ ip.is_private
20
+ or ip.is_loopback
21
+ or ip.is_link_local
22
+ or ip.is_multicast
23
+ or ip.is_reserved
24
+ or ip.is_unspecified
25
+ ):
26
+ return False
27
+ return True
28
+
29
+
30
+ def normalize_github_url(url: str) -> str:
31
+ parsed = urlparse(url)
32
+ if parsed.hostname == "github.com" and "/blob/" in parsed.path:
33
+ parts = parsed.path.strip("/").split("/")
34
+ if len(parts) < 5:
35
+ raise ValueError("GitHub URL must point to a workflow JSON file")
36
+ owner, repository, _, branch, *path = parts
37
+ return (
38
+ f"https://raw.githubusercontent.com/{owner}/{repository}/"
39
+ f"{branch}/{'/'.join(path)}"
40
+ )
41
+ return url
42
+
43
+
44
+ async def fetch_remote_json(url: str, github_only: bool = False) -> str:
45
+ target = normalize_github_url(url.strip())
46
+ parsed = urlparse(target)
47
+ if parsed.scheme != "https" or not parsed.hostname:
48
+ raise ValueError("Remote imports require an HTTPS URL")
49
+ if github_only and parsed.hostname not in ALLOWED_GITHUB_HOSTS:
50
+ raise ValueError("GitHub imports only accept github.com URLs")
51
+ if not _is_public_host(parsed.hostname):
52
+ raise ValueError("URL does not resolve to a public host")
53
+
54
+ async with httpx.AsyncClient(
55
+ timeout=httpx.Timeout(15, connect=5),
56
+ follow_redirects=False,
57
+ headers={"Accept": "application/json"},
58
+ ) as client:
59
+ response = await client.get(target)
60
+ response.raise_for_status()
61
+ if int(response.headers.get("content-length", "0") or 0) > MAX_IMPORT_BYTES:
62
+ raise ValueError("Remote workflow exceeds the 5 MB import limit")
63
+ content = response.content
64
+ if len(content) > MAX_IMPORT_BYTES:
65
+ raise ValueError("Remote workflow exceeds the 5 MB import limit")
66
+ return content.decode("utf-8")
app/services/repository.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+ from uuid import uuid4
3
+
4
+ import httpx
5
+
6
+ from app.core.config import Settings
7
+ from app.models.catalog import ProjectSummary
8
+ from app.models.workflow import SaveRequest, SaveResponse
9
+
10
+
11
+ class WorkflowRepository:
12
+ def __init__(self, settings: Settings):
13
+ self.settings = settings
14
+
15
+ @property
16
+ def configured(self) -> bool:
17
+ return bool(self.settings.supabase_url and self.settings.supabase_service_role_key)
18
+
19
+ def _headers(self) -> dict[str, str]:
20
+ return {
21
+ "apikey": self.settings.supabase_service_role_key,
22
+ "Authorization": f"Bearer {self.settings.supabase_service_role_key}",
23
+ "Content-Type": "application/json",
24
+ "Prefer": "return=representation",
25
+ }
26
+
27
+ async def save(self, request: SaveRequest, user_id: str) -> SaveResponse:
28
+ now = datetime.now(UTC).isoformat()
29
+ workflow_id = str(uuid4())
30
+ if not self.configured:
31
+ return SaveResponse(id=workflow_id, version=1, saved_at=now)
32
+
33
+ base_url = f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
34
+ async with httpx.AsyncClient(timeout=10) as client:
35
+ membership_response = await client.get(
36
+ (
37
+ f"{base_url}/workspace_members"
38
+ f"?select=workspace_id,role&user_id=eq.{user_id}"
39
+ ),
40
+ headers=self._headers(),
41
+ )
42
+ membership_response.raise_for_status()
43
+ memberships = membership_response.json()
44
+ editable_workspace_ids = {
45
+ item["workspace_id"]
46
+ for item in memberships
47
+ if item["role"] in {"owner", "admin", "editor"}
48
+ }
49
+ if not editable_workspace_ids:
50
+ raise ValueError("User does not have edit access to a workspace")
51
+
52
+ owner_id = user_id
53
+ workspace_id = next(iter(editable_workspace_ids))
54
+ if request.workflow.id:
55
+ existing_response = await client.get(
56
+ (
57
+ f"{base_url}/workflows"
58
+ f"?select=id,owner_id,workspace_id&id=eq.{request.workflow.id}&limit=1"
59
+ ),
60
+ headers=self._headers(),
61
+ )
62
+ existing_response.raise_for_status()
63
+ existing = existing_response.json()
64
+ if existing:
65
+ record = existing[0]
66
+ if record["workspace_id"] not in editable_workspace_ids:
67
+ raise ValueError("Workflow is not editable by this user")
68
+ workflow_id = record["id"]
69
+ workspace_id = record["workspace_id"]
70
+ owner_id = record["owner_id"]
71
+
72
+ if request.project_id:
73
+ project_response = await client.get(
74
+ (
75
+ f"{base_url}/projects?select=workspace_id"
76
+ f"&id=eq.{request.project_id}&limit=1"
77
+ ),
78
+ headers=self._headers(),
79
+ )
80
+ project_response.raise_for_status()
81
+ projects = project_response.json()
82
+ if (
83
+ not projects
84
+ or projects[0]["workspace_id"] not in editable_workspace_ids
85
+ or (
86
+ request.workflow.id
87
+ and workflow_id == request.workflow.id
88
+ and projects[0]["workspace_id"] != workspace_id
89
+ )
90
+ ):
91
+ raise ValueError("Project is not editable by this user")
92
+ workspace_id = projects[0]["workspace_id"]
93
+
94
+ definition = request.workflow.model_dump(mode="json")
95
+ definition["id"] = workflow_id
96
+ payload = {
97
+ "id": workflow_id,
98
+ "owner_id": owner_id,
99
+ "workspace_id": workspace_id,
100
+ "project_id": request.project_id,
101
+ "name": request.workflow.name,
102
+ "definition": definition,
103
+ "is_active": request.workflow.active,
104
+ "updated_at": now,
105
+ }
106
+ response = await client.post(
107
+ f"{base_url}/workflows?on_conflict=id",
108
+ headers={**self._headers(), "Prefer": "resolution=merge-duplicates,return=representation"},
109
+ json=payload,
110
+ )
111
+ response.raise_for_status()
112
+ version_response = await client.post(
113
+ f"{base_url}/workflow_versions",
114
+ headers=self._headers(),
115
+ json={
116
+ "workflow_id": workflow_id,
117
+ "created_by": user_id,
118
+ "definition": definition,
119
+ "change_summary": request.change_summary,
120
+ },
121
+ )
122
+ version_response.raise_for_status()
123
+ version_payload = version_response.json()[0]
124
+ return SaveResponse(
125
+ id=workflow_id,
126
+ version=version_payload["version_number"],
127
+ saved_at=version_payload["created_at"],
128
+ )
129
+
130
+ async def projects(self, user_id: str) -> list[ProjectSummary]:
131
+ if not self.configured:
132
+ return [
133
+ ProjectSummary(
134
+ id="demo-finance",
135
+ name="Finance Ops",
136
+ description="Invoice and reporting automations",
137
+ workflow_count=4,
138
+ updated_at=datetime.now(UTC).isoformat(),
139
+ )
140
+ ]
141
+ base_url = f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
142
+ async with httpx.AsyncClient(timeout=10) as client:
143
+ membership_response = await client.get(
144
+ f"{base_url}/workspace_members?select=workspace_id&user_id=eq.{user_id}",
145
+ headers=self._headers(),
146
+ )
147
+ membership_response.raise_for_status()
148
+ workspace_ids = [
149
+ item["workspace_id"] for item in membership_response.json()
150
+ ]
151
+ if not workspace_ids:
152
+ return []
153
+ workspace_filter = ",".join(workspace_ids)
154
+ response = await client.get(
155
+ (
156
+ f"{base_url}/projects"
157
+ "?select=id,name,description,updated_at"
158
+ f"&workspace_id=in.({workspace_filter})"
159
+ "&order=updated_at.desc"
160
+ ),
161
+ headers=self._headers(),
162
+ )
163
+ response.raise_for_status()
164
+ return [ProjectSummary(**item, workflow_count=0) for item in response.json()]
app/services/validation.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from collections import defaultdict
3
+ from typing import Protocol
4
+
5
+ from app.models.workflow import ValidationIssue, ValidationResult, WorkflowDocument
6
+ from app.services.catalog import SUPPORTED_NODE_TYPES
7
+
8
+ EXPRESSION_RE = re.compile(r"^=.*\{\{.+\}\}.*$", re.DOTALL)
9
+
10
+
11
+ class ValidationRule(Protocol):
12
+ code: str
13
+
14
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: ...
15
+
16
+
17
+ class TriggerRule:
18
+ code = "missing_trigger"
19
+
20
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
21
+ if any(node.data.category == "trigger" for node in workflow.nodes):
22
+ return []
23
+ return [
24
+ ValidationIssue(
25
+ code=self.code,
26
+ severity="error",
27
+ message="Workflow does not have a trigger node.",
28
+ suggestion="Add a Webhook, Schedule Trigger, or service trigger.",
29
+ )
30
+ ]
31
+
32
+
33
+ class CredentialRule:
34
+ code = "missing_credentials"
35
+
36
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
37
+ issues = []
38
+ for node in workflow.nodes:
39
+ for credential in (node.data.credentials or {}).values():
40
+ if credential.id.startswith("__"):
41
+ issues.append(
42
+ ValidationIssue(
43
+ code=self.code,
44
+ severity="error",
45
+ nodeId=node.id,
46
+ message=f"{node.data.label} has an unconfigured credential.",
47
+ suggestion=f"Select a {credential.type} credential before activation.",
48
+ )
49
+ )
50
+ return issues
51
+
52
+
53
+ class ConnectionRule:
54
+ code = "broken_connection"
55
+
56
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
57
+ ids = {node.id for node in workflow.nodes}
58
+ issues = []
59
+ for edge in workflow.edges:
60
+ if edge.source not in ids or edge.target not in ids:
61
+ issues.append(
62
+ ValidationIssue(
63
+ code=self.code,
64
+ severity="error",
65
+ message=f"Connection {edge.id} references a missing node.",
66
+ suggestion="Delete the connection or reconnect it to an existing node.",
67
+ )
68
+ )
69
+ connected = {edge.source for edge in workflow.edges} | {
70
+ edge.target for edge in workflow.edges
71
+ }
72
+ for node in workflow.nodes:
73
+ if len(workflow.nodes) > 1 and node.id not in connected:
74
+ issues.append(
75
+ ValidationIssue(
76
+ code="disconnected_node",
77
+ severity="warning",
78
+ nodeId=node.id,
79
+ message=f"{node.data.label} is not connected.",
80
+ suggestion="Connect or remove this node.",
81
+ )
82
+ )
83
+ return issues
84
+
85
+
86
+ class LoopRule:
87
+ code = "infinite_loop"
88
+
89
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
90
+ graph: dict[str, list[str]] = defaultdict(list)
91
+ for edge in workflow.edges:
92
+ graph[edge.source].append(edge.target)
93
+ visiting: set[str] = set()
94
+ visited: set[str] = set()
95
+
96
+ def has_cycle(node_id: str) -> bool:
97
+ if node_id in visiting:
98
+ return True
99
+ if node_id in visited:
100
+ return False
101
+ visiting.add(node_id)
102
+ if any(has_cycle(target) for target in graph[node_id]):
103
+ return True
104
+ visiting.remove(node_id)
105
+ visited.add(node_id)
106
+ return False
107
+
108
+ if any(has_cycle(node.id) for node in workflow.nodes if node.id not in visited):
109
+ return [
110
+ ValidationIssue(
111
+ code=self.code,
112
+ severity="error",
113
+ message="Workflow contains a cycle that may execute indefinitely.",
114
+ suggestion="Break the cycle or add an explicit loop termination condition.",
115
+ )
116
+ ]
117
+ return []
118
+
119
+
120
+ class ParameterRule:
121
+ code = "empty_parameters"
122
+
123
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
124
+ issues = []
125
+ for node in workflow.nodes:
126
+ if node.data.category != "trigger" and not node.data.parameters:
127
+ issues.append(
128
+ ValidationIssue(
129
+ code=self.code,
130
+ severity="warning",
131
+ nodeId=node.id,
132
+ message=f"{node.data.label} has no parameters.",
133
+ suggestion="Configure the required operation and resource fields.",
134
+ )
135
+ )
136
+ return issues
137
+
138
+
139
+ class ExpressionRule:
140
+ code = "invalid_expression"
141
+
142
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
143
+ issues = []
144
+
145
+ def inspect(value, node_id: str, label: str) -> None:
146
+ if isinstance(value, dict):
147
+ for nested in value.values():
148
+ inspect(nested, node_id, label)
149
+ elif isinstance(value, list):
150
+ for nested in value:
151
+ inspect(nested, node_id, label)
152
+ elif isinstance(value, str) and ("{{" in value or "}}" in value):
153
+ if value.count("{{") != value.count("}}") or not EXPRESSION_RE.match(value):
154
+ issues.append(
155
+ ValidationIssue(
156
+ code=self.code,
157
+ severity="error",
158
+ nodeId=node_id,
159
+ message=f"{label} contains an invalid n8n expression.",
160
+ suggestion="Expressions should start with = and use balanced {{ }} braces.",
161
+ )
162
+ )
163
+
164
+ for node in workflow.nodes:
165
+ inspect(node.data.parameters, node.id, node.data.label)
166
+ return issues
167
+
168
+
169
+ class SupportedNodeRule:
170
+ code = "unsupported_node"
171
+
172
+ def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
173
+ return [
174
+ ValidationIssue(
175
+ code=self.code,
176
+ severity="warning",
177
+ nodeId=node.id,
178
+ message=f"{node.data.label} is not in the bundled node catalog.",
179
+ suggestion="Confirm that the node package is installed on the target n8n instance.",
180
+ )
181
+ for node in workflow.nodes
182
+ if node.data.type not in SUPPORTED_NODE_TYPES
183
+ and not node.data.type.startswith("@n8n/n8n-nodes-langchain.")
184
+ ]
185
+
186
+
187
+ class Validator:
188
+ def __init__(self) -> None:
189
+ self.rules: list[ValidationRule] = []
190
+
191
+ def register(self, rule: ValidationRule) -> None:
192
+ self.rules.append(rule)
193
+
194
+ def validate(self, workflow: WorkflowDocument) -> ValidationResult:
195
+ issues = [issue for rule in self.rules for issue in rule.validate(workflow)]
196
+ deductions = {"error": 18, "warning": 6, "info": 1}
197
+ score = max(0, 100 - sum(deductions[issue.severity] for issue in issues))
198
+ return ValidationResult(
199
+ valid=not any(issue.severity == "error" for issue in issues),
200
+ score=score,
201
+ issues=issues,
202
+ )
203
+
204
+
205
+ validator = Validator()
206
+ for validation_rule in (
207
+ TriggerRule(),
208
+ CredentialRule(),
209
+ ConnectionRule(),
210
+ LoopRule(),
211
+ ParameterRule(),
212
+ ExpressionRule(),
213
+ SupportedNodeRule(),
214
+ ):
215
+ validator.register(validation_rule)
requirements.txt CHANGED
@@ -1,3 +1,9 @@
1
- google-genai==1.5.0
2
- gradio
3
- pydantic==2.10.6
 
 
 
 
 
 
 
1
+ fastapi==0.139.0
2
+ uvicorn[standard]==0.51.0
3
+ pydantic==2.13.4
4
+ pydantic-settings==2.14.2
5
+ httpx==0.28.1
6
+ PyJWT[crypto]==2.10.1
7
+ python-multipart==0.0.20
8
+ pytest==8.3.4
9
+ pytest-asyncio==0.25.0
tests/__pycache__/conftest.cpython-314.pyc ADDED
Binary file (582 Bytes). View file
 
tests/__pycache__/test_api.cpython-314.pyc ADDED
Binary file (10.8 kB). View file
 
tests/conftest.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ os.environ["ENVIRONMENT"] = "test"
4
+ os.environ["AUTH_REQUIRED"] = "false"
5
+
6
+ import pytest
7
+ from fastapi.testclient import TestClient
8
+
9
+ from app.main import app
10
+
11
+
12
+ @pytest.fixture
13
+ def client():
14
+ return TestClient(app)
tests/test_api.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_health(client):
2
+ response = client.get("/health")
3
+ assert response.status_code == 200
4
+ assert response.json()["status"] == "ok"
5
+
6
+
7
+ def test_generate_and_validate_workflow(client):
8
+ generated = client.post(
9
+ "/generate-workflow",
10
+ json={
11
+ "prompt": (
12
+ "Read Gmail invoice attachments, extract the data, store it in "
13
+ "Supabase, and send a Slack notification"
14
+ )
15
+ },
16
+ )
17
+ assert generated.status_code == 200
18
+ workflow = generated.json()["workflow"]
19
+ assert len(workflow["nodes"]) == 4
20
+ assert len(workflow["edges"]) == 3
21
+
22
+ validation = client.post("/validate", json={"workflow": workflow})
23
+ assert validation.status_code == 200
24
+ body = validation.json()
25
+ assert body["valid"] is False
26
+ assert any(issue["code"] == "missing_credentials" for issue in body["issues"])
27
+
28
+
29
+ def test_cycle_detection(client):
30
+ workflow = {
31
+ "name": "Cycle",
32
+ "active": False,
33
+ "nodes": [
34
+ {
35
+ "id": "a",
36
+ "type": "workflow",
37
+ "position": {"x": 0, "y": 0},
38
+ "data": {
39
+ "label": "Webhook",
40
+ "type": "n8n-nodes-base.webhook",
41
+ "typeVersion": 2,
42
+ "category": "trigger",
43
+ "parameters": {"path": "test"},
44
+ },
45
+ },
46
+ {
47
+ "id": "b",
48
+ "type": "workflow",
49
+ "position": {"x": 300, "y": 0},
50
+ "data": {
51
+ "label": "Edit Fields",
52
+ "type": "n8n-nodes-base.set",
53
+ "typeVersion": 3,
54
+ "category": "core",
55
+ "parameters": {"value": 1},
56
+ },
57
+ },
58
+ ],
59
+ "edges": [
60
+ {"id": "a-b", "source": "a", "target": "b"},
61
+ {"id": "b-a", "source": "b", "target": "a"},
62
+ ],
63
+ "settings": {},
64
+ "meta": {},
65
+ }
66
+ response = client.post("/validate", json={"workflow": workflow})
67
+ assert response.status_code == 200
68
+ assert any(issue["code"] == "infinite_loop" for issue in response.json()["issues"])
69
+
70
+
71
+ def test_n8n_import_export_round_trip(client):
72
+ n8n = {
73
+ "name": "Round trip",
74
+ "nodes": [
75
+ {
76
+ "id": "one",
77
+ "name": "Webhook",
78
+ "type": "n8n-nodes-base.webhook",
79
+ "typeVersion": 2,
80
+ "position": [10, 20],
81
+ "parameters": {"path": "incoming"},
82
+ },
83
+ {
84
+ "id": "two",
85
+ "name": "Edit Fields",
86
+ "type": "n8n-nodes-base.set",
87
+ "typeVersion": 3,
88
+ "position": [300, 20],
89
+ "parameters": {"assignments": {}},
90
+ },
91
+ ],
92
+ "connections": {
93
+ "Webhook": {
94
+ "main": [[{"node": "Edit Fields", "type": "main", "index": 0}]]
95
+ }
96
+ },
97
+ "settings": {},
98
+ }
99
+ imported = client.post(
100
+ "/import",
101
+ json={"content": __import__("json").dumps(n8n), "source": "json"},
102
+ )
103
+ assert imported.status_code == 200
104
+ workflow = imported.json()
105
+ assert len(workflow["edges"]) == 1
106
+
107
+ exported = client.post(
108
+ "/export", json={"workflow": workflow, "format": "n8n"}
109
+ )
110
+ assert exported.status_code == 200
111
+ assert exported.json()["connections"]["Webhook"]["main"][0][0]["node"] == "Edit Fields"
112
+
113
+
114
+ def test_simulation_testing_cost_and_diff(client):
115
+ generated = client.post(
116
+ "/generate-workflow",
117
+ json={"prompt": "Create a webhook workflow that stores data in Supabase"},
118
+ ).json()["workflow"]
119
+
120
+ simulation = client.post(
121
+ "/simulate",
122
+ json={"workflow": generated, "input_data": {"email": "a@example.com"}},
123
+ )
124
+ assert simulation.status_code == 200
125
+ assert simulation.json()["status"] == "success"
126
+ assert len(simulation.json()["trace"]) == len(generated["nodes"])
127
+
128
+ expected_last = generated["nodes"][-1]["data"]["label"]
129
+ tests = client.post(
130
+ "/test-workflow",
131
+ json={
132
+ "workflow": generated,
133
+ "cases": [
134
+ {
135
+ "name": "happy path",
136
+ "input_data": {},
137
+ "assertions": [
138
+ {"path": "_lastNode", "operator": "equals", "expected": expected_last}
139
+ ],
140
+ }
141
+ ],
142
+ },
143
+ )
144
+ assert tests.status_code == 200
145
+ assert tests.json()["passed"] == 1
146
+
147
+ cost = client.post(
148
+ "/estimate-cost",
149
+ json={"workflow": generated, "executions_per_month": 10_000},
150
+ )
151
+ assert cost.status_code == 200
152
+ assert cost.json()["executions_per_month"] == 10_000
153
+
154
+ changed = {**generated, "name": "Changed", "nodes": generated["nodes"][:-1]}
155
+ diff = client.post("/diff", json={"before": generated, "after": changed})
156
+ assert diff.status_code == 200
157
+ assert len(diff.json()["removed_nodes"]) == 1
158
+
159
+
160
+ def test_local_share_link_round_trip(client):
161
+ workflow = client.post(
162
+ "/generate-workflow",
163
+ json={"prompt": "Create a webhook workflow that sends a Slack message"},
164
+ ).json()["workflow"]
165
+ workflow["id"] = "local-workflow"
166
+ shared = client.post(
167
+ "/shares",
168
+ json={
169
+ "workflow_id": workflow["id"],
170
+ "workflow": workflow,
171
+ "permission": "copy",
172
+ "expires_in_days": 7,
173
+ },
174
+ )
175
+ assert shared.status_code == 200
176
+ token = shared.json()["url"].rsplit("/", 1)[-1]
177
+ public = client.get(f"/shares/{token}")
178
+ assert public.status_code == 200
179
+ assert public.json()["permission"] == "copy"
180
+ assert public.json()["workflow"]["name"] == workflow["name"]
181
+
182
+
183
+ def test_intelligence_release_and_package_endpoints(client):
184
+ workflow = client.post(
185
+ "/generate-workflow",
186
+ json={"prompt": "Read customer email invoices and store amounts in Supabase"},
187
+ ).json()["workflow"]
188
+
189
+ quality = client.post("/quality", json={"workflow": workflow})
190
+ assert quality.status_code == 200
191
+ assert 0 <= quality.json()["overall"] <= 100
192
+
193
+ lineage = client.post("/lineage", json={"workflow": workflow})
194
+ assert lineage.status_code == 200
195
+ assert "fields" in lineage.json()
196
+
197
+ contract = client.post(
198
+ "/contract-test",
199
+ json={
200
+ "workflow": workflow,
201
+ "sample_data": {"email": "a@example.com", "amount": 15},
202
+ "expected_schema": {"email": "string", "amount": "number"},
203
+ },
204
+ )
205
+ assert contract.status_code == 200
206
+ assert contract.json()["valid"] is True
207
+
208
+ release = client.post(
209
+ "/release-plan",
210
+ json={"workflow": workflow, "strategy": "shadow", "traffic_percentage": 0},
211
+ )
212
+ assert release.status_code == 200
213
+ assert release.json()["requires_approval"] is True
214
+
215
+ package = client.post(
216
+ "/package",
217
+ json={"workflow": workflow, "tests": [], "contracts": {}, "environments": {}},
218
+ )
219
+ assert package.status_code == 200
220
+ assert package.json()["manifest"]["format"] == "flowforge.workflow-package/v1"
221
+
222
+
223
+ def test_webhook_redaction_and_replay(client):
224
+ workflow = client.post(
225
+ "/generate-workflow",
226
+ json={"prompt": "Create a webhook that sends a notification"},
227
+ ).json()["workflow"]
228
+ inspected = client.post(
229
+ "/inspect-webhook",
230
+ json={"payload": {"email": "a@example.com", "token": "secret", "event": "paid"}},
231
+ )
232
+ assert inspected.status_code == 200
233
+ assert inspected.json()["payload"]["email"] == "[REDACTED]"
234
+ assert inspected.json()["payload"]["token"] == "[REDACTED]"
235
+
236
+ replay = client.post(
237
+ "/replay",
238
+ json={"workflow": workflow, "node_id": workflow["nodes"][-1]["id"], "input_data": {}},
239
+ )
240
+ assert replay.status_code == 200
241
+ assert len(replay.json()["trace"]) == 1