Ava2lon commited on
Commit
5ad7cb7
·
verified ·
1 Parent(s): d884cb4

Delete app

Browse files
Files changed (41) hide show
  1. app/__init__.py +0 -1
  2. app/__pycache__/__init__.cpython-314.pyc +0 -0
  3. app/__pycache__/main.cpython-314.pyc +0 -0
  4. app/api/__pycache__/routes.cpython-314.pyc +0 -0
  5. app/api/routes.py +0 -482
  6. app/core/__pycache__/config.cpython-314.pyc +0 -0
  7. app/core/__pycache__/rate_limit.cpython-314.pyc +0 -0
  8. app/core/__pycache__/security.cpython-314.pyc +0 -0
  9. app/core/config.py +0 -72
  10. app/core/errors.py +0 -3
  11. app/core/rate_limit.py +0 -45
  12. app/core/security.py +0 -65
  13. app/main.py +0 -36
  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 +0 -67
  17. app/models/workflow.py +0 -465
  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 +0 -178
  31. app/services/catalog.py +0 -131
  32. app/services/chat.py +0 -84
  33. app/services/collaboration.py +0 -248
  34. app/services/deployment.py +0 -43
  35. app/services/generator.py +0 -411
  36. app/services/intelligence.py +0 -300
  37. app/services/operations.py +0 -230
  38. app/services/optimizer.py +0 -83
  39. app/services/remote.py +0 -66
  40. app/services/repository.py +0 -411
  41. app/services/validation.py +0 -215
app/__init__.py DELETED
@@ -1 +0,0 @@
1
- """FlowForge API package."""
 
 
app/__pycache__/__init__.cpython-314.pyc DELETED
Binary file (151 Bytes)
 
app/__pycache__/main.cpython-314.pyc DELETED
Binary file (1.74 kB)
 
app/api/__pycache__/routes.cpython-314.pyc DELETED
Binary file (29.3 kB)
 
app/api/routes.py DELETED
@@ -1,482 +0,0 @@
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.core.errors import ServiceConfigurationError
10
- from app.models.catalog import DashboardSummary, NodeDefinition, ProjectSummary, TemplateSummary
11
- from app.models.workflow import (
12
- ChatRequest,
13
- ChatResponse,
14
- CommentRequest,
15
- ContractRequest,
16
- ContractResponse,
17
- CostEstimate,
18
- CostEstimateRequest,
19
- DeploymentRequest,
20
- DeploymentResponse,
21
- DependencyImpactRequest,
22
- DependencyImpactResponse,
23
- DocumentationResponse,
24
- EnvironmentPromotionRequest,
25
- EnvironmentPromotionResponse,
26
- ExportRequest,
27
- ExpressionRequest,
28
- ExpressionResponse,
29
- GenerateWorkflowRequest,
30
- GenerateWorkflowResponse,
31
- ImportRequest,
32
- IntentDriftRequest,
33
- IntentDriftResponse,
34
- LineageResponse,
35
- OptimizationResponse,
36
- QualityResponse,
37
- ReleasePlanRequest,
38
- ReleasePlanResponse,
39
- ReplayRequest,
40
- RoiRequest,
41
- RoiResponse,
42
- SaveRequest,
43
- SaveResponse,
44
- ShareRequest,
45
- ShareResponse,
46
- SharedWorkflowResponse,
47
- SimulationRequest,
48
- SimulationResponse,
49
- TestWorkflowRequest,
50
- TestWorkflowResponse,
51
- SelfHealRequest,
52
- SelfHealResponse,
53
- ValidationResult,
54
- VersionSummary,
55
- WorkflowComment,
56
- WorkflowDiff,
57
- WorkflowDiffRequest,
58
- WorkflowDocument,
59
- WorkflowPackageRequest,
60
- WorkflowPackageResponse,
61
- WorkflowRequest,
62
- WebhookInspectRequest,
63
- WebhookInspectResponse,
64
- )
65
- from app.services.adapters import adapters
66
- from app.services.catalog import NODE_CATALOG
67
- from app.services.chat import chat_service
68
- from app.services.collaboration import CollaborationRepository
69
- from app.services.deployment import deploy_to_n8n
70
- from app.services.generator import providers
71
- from app.services.intelligence import (
72
- contract_check,
73
- dependency_impact,
74
- documentation,
75
- inspect_webhook,
76
- intent_drift,
77
- lineage,
78
- package_workflow,
79
- promote,
80
- quality,
81
- release_plan,
82
- roi,
83
- self_heal,
84
- )
85
- from app.services.optimizer import optimizer
86
- from app.services.operations import (
87
- diff_workflows,
88
- estimate_cost,
89
- replay_from_node,
90
- run_test,
91
- simulate,
92
- )
93
- from app.services.repository import WorkflowRepository
94
- from app.services.remote import fetch_remote_json
95
- from app.services.validation import validator
96
-
97
- router = APIRouter()
98
- User = Annotated[CurrentUser, Depends(get_current_user)]
99
- AppSettings = Annotated[Settings, Depends(get_settings)]
100
-
101
-
102
- @router.post("/generate-workflow", response_model=GenerateWorkflowResponse)
103
- async def generate_workflow(
104
- request: GenerateWorkflowRequest,
105
- user: User,
106
- settings: AppSettings,
107
- ) -> GenerateWorkflowResponse:
108
- try:
109
- provider = providers.get(request.provider or settings.ai_provider)
110
- return await provider.generate(request.prompt, request.model)
111
- except ValueError as exc:
112
- raise HTTPException(status_code=422, detail=str(exc)) from exc
113
- except (RuntimeError, httpx.HTTPError) as exc:
114
- raise HTTPException(status_code=502, detail=f"Workflow generation failed: {exc}") from exc
115
-
116
-
117
- @router.post("/validate", response_model=ValidationResult)
118
- async def validate_workflow(request: WorkflowRequest, user: User) -> ValidationResult:
119
- return validator.validate(request.workflow)
120
-
121
-
122
- @router.post("/optimize", response_model=OptimizationResponse)
123
- async def optimize_workflow(request: WorkflowRequest, user: User) -> OptimizationResponse:
124
- return optimizer.optimize(request.workflow)
125
-
126
-
127
- @router.post("/simulate", response_model=SimulationResponse)
128
- async def simulate_workflow(request: SimulationRequest, user: User) -> SimulationResponse:
129
- return simulate(request.workflow, request.input_data)
130
-
131
-
132
- @router.post("/test-workflow", response_model=TestWorkflowResponse)
133
- async def test_workflow(request: TestWorkflowRequest, user: User) -> TestWorkflowResponse:
134
- results = [run_test(request.workflow, case) for case in request.cases]
135
- return TestWorkflowResponse(
136
- passed=sum(result.passed for result in results),
137
- failed=sum(not result.passed for result in results),
138
- results=results,
139
- )
140
-
141
-
142
- @router.post("/estimate-cost", response_model=CostEstimate)
143
- async def workflow_cost(request: CostEstimateRequest, user: User) -> CostEstimate:
144
- return estimate_cost(request.workflow, request.executions_per_month)
145
-
146
-
147
- @router.post("/diff", response_model=WorkflowDiff)
148
- async def workflow_diff(request: WorkflowDiffRequest, user: User) -> WorkflowDiff:
149
- return diff_workflows(request.before, request.after)
150
-
151
-
152
- @router.post("/lineage", response_model=LineageResponse)
153
- async def workflow_lineage(request: WorkflowRequest, user: User) -> LineageResponse:
154
- return lineage(request.workflow)
155
-
156
-
157
- @router.post("/contract-test", response_model=ContractResponse)
158
- async def contract_test(request: ContractRequest, user: User) -> ContractResponse:
159
- return contract_check(request.sample_data, request.expected_schema)
160
-
161
-
162
- @router.post("/quality", response_model=QualityResponse)
163
- async def workflow_quality(request: WorkflowRequest, user: User) -> QualityResponse:
164
- return quality(request.workflow)
165
-
166
-
167
- @router.post("/intent-drift", response_model=IntentDriftResponse)
168
- async def workflow_intent_drift(
169
- request: IntentDriftRequest, user: User
170
- ) -> IntentDriftResponse:
171
- return intent_drift(request.workflow, request.requirement)
172
-
173
-
174
- @router.post("/replay", response_model=SimulationResponse)
175
- async def replay(request: ReplayRequest, user: User) -> SimulationResponse:
176
- return replay_from_node(request.workflow, request.node_id, request.input_data)
177
-
178
-
179
- @router.post("/promote", response_model=EnvironmentPromotionResponse)
180
- async def promote_environment(
181
- request: EnvironmentPromotionRequest, user: User
182
- ) -> EnvironmentPromotionResponse:
183
- return promote(request.workflow, request.environment, request.values)
184
-
185
-
186
- @router.post("/release-plan", response_model=ReleasePlanResponse)
187
- async def create_release_plan(
188
- request: ReleasePlanRequest, user: User
189
- ) -> ReleasePlanResponse:
190
- return release_plan(request)
191
-
192
-
193
- @router.post("/package", response_model=WorkflowPackageResponse)
194
- async def create_workflow_package(
195
- request: WorkflowPackageRequest, user: User
196
- ) -> WorkflowPackageResponse:
197
- return package_workflow(request)
198
-
199
-
200
- @router.post("/documentation", response_model=DocumentationResponse)
201
- async def generate_documentation(
202
- request: WorkflowRequest, user: User
203
- ) -> DocumentationResponse:
204
- return documentation(request.workflow)
205
-
206
-
207
- @router.post("/roi", response_model=RoiResponse)
208
- async def calculate_roi(request: RoiRequest, user: User) -> RoiResponse:
209
- return roi(
210
- request.workflow,
211
- request.executions_per_month,
212
- request.minutes_saved_per_execution,
213
- request.hourly_rate_usd,
214
- request.sla_minutes,
215
- )
216
-
217
-
218
- @router.post("/inspect-webhook", response_model=WebhookInspectResponse)
219
- async def inspect_webhook_payload(
220
- request: WebhookInspectRequest, user: User
221
- ) -> WebhookInspectResponse:
222
- return inspect_webhook(request.payload, request.redact)
223
-
224
-
225
- @router.post("/dependency-impact", response_model=DependencyImpactResponse)
226
- async def analyze_dependency_impact(
227
- request: DependencyImpactRequest, user: User
228
- ) -> DependencyImpactResponse:
229
- return dependency_impact(request.workflow, request.dependency)
230
-
231
-
232
- @router.post("/self-heal", response_model=SelfHealResponse)
233
- async def propose_self_heal(request: SelfHealRequest, user: User) -> SelfHealResponse:
234
- return self_heal(request.workflow, request.errors)
235
-
236
-
237
- @router.post("/chat", response_model=ChatResponse)
238
- async def chat(request: ChatRequest, user: User) -> ChatResponse:
239
- return await chat_service.respond(request.message, request.workflow)
240
-
241
-
242
- @router.post("/generate-expression", response_model=ExpressionResponse)
243
- async def generate_expression(request: ExpressionRequest, user: User) -> ExpressionResponse:
244
- description = request.description.lower()
245
- if "email" in description:
246
- expression = "={{ $json.email }}"
247
- elif "name" in description:
248
- expression = "={{ $json.name }}"
249
- elif "current" in description and ("date" in description or "time" in description):
250
- expression = "={{ $now }}"
251
- elif "index" in description:
252
- expression = "={{ $itemIndex }}"
253
- elif request.context.get("node"):
254
- node_name = str(request.context["node"])
255
- expression = f'={{{{ $node["{node_name}"].json.data }}}}'
256
- else:
257
- expression = "={{ $json }}"
258
- return ExpressionResponse(
259
- expression=expression,
260
- explanation="The expression reads data from the current n8n item at execution time.",
261
- alternatives=["={{ $json }}", "={{ $itemIndex }}", "={{ $now }}"],
262
- )
263
-
264
-
265
- @router.post("/import", response_model=WorkflowDocument)
266
- async def import_workflow(request: ImportRequest, user: User) -> WorkflowDocument:
267
- content = request.content
268
- if request.source in {"url", "github"}:
269
- try:
270
- content = await fetch_remote_json(
271
- request.content,
272
- github_only=request.source == "github",
273
- )
274
- except (ValueError, httpx.HTTPError, UnicodeDecodeError) as exc:
275
- raise HTTPException(status_code=422, detail=f"Remote import failed: {exc}") from exc
276
- adapter = adapters.importers.get(
277
- "json" if request.source in {"url", "github"} else request.source
278
- )
279
- if not adapter:
280
- raise HTTPException(status_code=400, detail="Unsupported import source")
281
- try:
282
- return adapter.load(content)
283
- except (json.JSONDecodeError, ValueError, TypeError) as exc:
284
- raise HTTPException(status_code=422, detail=f"Invalid workflow: {exc}") from exc
285
-
286
-
287
- @router.post("/export")
288
- async def export_workflow(request: ExportRequest, user: User):
289
- if request.format == "internal":
290
- return request.workflow.model_dump(mode="json")
291
- adapter = adapters.exporters.get(request.format)
292
- if not adapter:
293
- raise HTTPException(status_code=400, detail="Unsupported export format")
294
- return adapter.dump(request.workflow)
295
-
296
-
297
- @router.get("/templates")
298
- async def list_templates(
299
- user: User,
300
- settings: AppSettings,
301
- q: str = Query(default="", max_length=200),
302
- category: str | None = Query(default=None, max_length=80),
303
- limit: int = Query(default=24, ge=1, le=100),
304
- offset: int = Query(default=0, ge=0),
305
- ) -> dict[str, list[TemplateSummary] | int]:
306
- try:
307
- items, total = await WorkflowRepository(settings).templates(
308
- user.id,
309
- query=q.strip(),
310
- category=category,
311
- limit=limit,
312
- offset=offset,
313
- )
314
- return {"items": items, "total": total}
315
- except ServiceConfigurationError as exc:
316
- raise HTTPException(status_code=503, detail=str(exc)) from exc
317
-
318
-
319
- @router.get("/templates/{template_id}", response_model=WorkflowDocument)
320
- async def get_template(
321
- template_id: str, user: User, settings: AppSettings
322
- ) -> WorkflowDocument:
323
- try:
324
- return await WorkflowRepository(settings).template(template_id, user.id)
325
- except ServiceConfigurationError as exc:
326
- raise HTTPException(status_code=503, detail=str(exc)) from exc
327
- except ValueError as exc:
328
- raise HTTPException(status_code=404, detail=str(exc)) from exc
329
-
330
-
331
- @router.get("/nodes")
332
- async def list_nodes(
333
- user: User,
334
- q: str = Query(default="", max_length=200),
335
- category: str | None = Query(default=None, max_length=80),
336
- limit: int = Query(default=100, ge=1, le=500),
337
- offset: int = Query(default=0, ge=0),
338
- ) -> dict[str, list[NodeDefinition] | int]:
339
- query = q.lower().strip()
340
- items = [
341
- node
342
- for node in NODE_CATALOG
343
- if (
344
- not query
345
- or query in f"{node.displayName} {node.description} {node.type}".lower()
346
- )
347
- and (not category or node.category.lower() == category.lower())
348
- ]
349
- return {"items": items[offset : offset + limit], "total": len(items)}
350
-
351
-
352
- @router.get("/projects", response_model=list[ProjectSummary])
353
- async def list_projects(user: User, settings: AppSettings) -> list[ProjectSummary]:
354
- try:
355
- return await WorkflowRepository(settings).projects(user.id)
356
- except ServiceConfigurationError as exc:
357
- raise HTTPException(status_code=503, detail=str(exc)) from exc
358
-
359
-
360
- @router.get("/dashboard", response_model=DashboardSummary)
361
- async def dashboard(user: User, settings: AppSettings) -> DashboardSummary:
362
- try:
363
- return await WorkflowRepository(settings).dashboard(user.id, user.email)
364
- except ServiceConfigurationError as exc:
365
- raise HTTPException(status_code=503, detail=str(exc)) from exc
366
-
367
-
368
- @router.get("/workflows/{workflow_id}", response_model=WorkflowDocument)
369
- async def get_workflow(
370
- workflow_id: str, user: User, settings: AppSettings
371
- ) -> WorkflowDocument:
372
- try:
373
- return await WorkflowRepository(settings).workflow(workflow_id, user.id)
374
- except ServiceConfigurationError as exc:
375
- raise HTTPException(status_code=503, detail=str(exc)) from exc
376
- except ValueError as exc:
377
- raise HTTPException(status_code=404, detail=str(exc)) from exc
378
-
379
-
380
- @router.post("/save", response_model=SaveResponse)
381
- async def save_workflow(
382
- request: SaveRequest,
383
- user: User,
384
- settings: AppSettings,
385
- ) -> SaveResponse:
386
- try:
387
- return await WorkflowRepository(settings).save(request, user.id)
388
- except ServiceConfigurationError as exc:
389
- raise HTTPException(status_code=503, detail=str(exc)) from exc
390
- except ValueError as exc:
391
- raise HTTPException(status_code=403, detail=str(exc)) from exc
392
-
393
-
394
- @router.post("/shares", response_model=ShareResponse)
395
- async def create_share(
396
- request: ShareRequest, user: User, settings: AppSettings
397
- ) -> ShareResponse:
398
- try:
399
- return await CollaborationRepository(settings).create_share(request, user.id)
400
- except ServiceConfigurationError as exc:
401
- raise HTTPException(status_code=503, detail=str(exc)) from exc
402
- except ValueError as exc:
403
- raise HTTPException(status_code=403, detail=str(exc)) from exc
404
-
405
-
406
- @router.get("/shares/{token}", response_model=SharedWorkflowResponse)
407
- async def get_shared_workflow(token: str, settings: AppSettings) -> SharedWorkflowResponse:
408
- if len(token) < 32 or len(token) > 128:
409
- raise HTTPException(status_code=404, detail="Share link was not found")
410
- try:
411
- return await CollaborationRepository(settings).shared_workflow(token)
412
- except ServiceConfigurationError as exc:
413
- raise HTTPException(status_code=503, detail=str(exc)) from exc
414
- except ValueError as exc:
415
- raise HTTPException(status_code=404, detail=str(exc)) from exc
416
-
417
-
418
- @router.get("/workflows/{workflow_id}/versions", response_model=list[VersionSummary])
419
- async def list_versions(
420
- workflow_id: str, user: User, settings: AppSettings
421
- ) -> list[VersionSummary]:
422
- try:
423
- return await CollaborationRepository(settings).versions(workflow_id, user.id)
424
- except ServiceConfigurationError as exc:
425
- raise HTTPException(status_code=503, detail=str(exc)) from exc
426
- except ValueError as exc:
427
- raise HTTPException(status_code=403, detail=str(exc)) from exc
428
-
429
-
430
- @router.post("/workflows/{workflow_id}/versions/{version}/restore", response_model=WorkflowDocument)
431
- async def restore_version(
432
- workflow_id: str, version: int, user: User, settings: AppSettings
433
- ) -> WorkflowDocument:
434
- try:
435
- return await CollaborationRepository(settings).restore(workflow_id, version, user.id)
436
- except ServiceConfigurationError as exc:
437
- raise HTTPException(status_code=503, detail=str(exc)) from exc
438
- except ValueError as exc:
439
- raise HTTPException(status_code=403, detail=str(exc)) from exc
440
-
441
-
442
- @router.get("/workflows/{workflow_id}/comments", response_model=list[WorkflowComment])
443
- async def list_comments(
444
- workflow_id: str, user: User, settings: AppSettings
445
- ) -> list[WorkflowComment]:
446
- try:
447
- return await CollaborationRepository(settings).comments(workflow_id, user.id)
448
- except ServiceConfigurationError as exc:
449
- raise HTTPException(status_code=503, detail=str(exc)) from exc
450
- except ValueError as exc:
451
- raise HTTPException(status_code=403, detail=str(exc)) from exc
452
-
453
-
454
- @router.post("/comments", response_model=WorkflowComment)
455
- async def add_comment(
456
- request: CommentRequest, user: User, settings: AppSettings
457
- ) -> WorkflowComment:
458
- try:
459
- return await CollaborationRepository(settings).add_comment(
460
- request.workflow_id, request.body, request.node_id, user.id
461
- )
462
- except ServiceConfigurationError as exc:
463
- raise HTTPException(status_code=503, detail=str(exc)) from exc
464
- except ValueError as exc:
465
- raise HTTPException(status_code=403, detail=str(exc)) from exc
466
-
467
-
468
- @router.post("/deploy", response_model=DeploymentResponse)
469
- async def deploy(
470
- request: DeploymentRequest, user: User, settings: AppSettings
471
- ) -> DeploymentResponse:
472
- try:
473
- authorized = False
474
- if request.workflow.id:
475
- authorized = await CollaborationRepository(settings).authorize_deploy(
476
- request.workflow.id, user.id
477
- )
478
- return await deploy_to_n8n(
479
- request.workflow, request.activate, settings, authorized=authorized
480
- )
481
- except (ValueError, httpx.HTTPError) as exc:
482
- raise HTTPException(status_code=422, detail=f"Deployment failed: {exc}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/__pycache__/config.cpython-314.pyc DELETED
Binary file (2.75 kB)
 
app/core/__pycache__/rate_limit.cpython-314.pyc DELETED
Binary file (3.79 kB)
 
app/core/__pycache__/security.cpython-314.pyc DELETED
Binary file (3.64 kB)
 
app/core/config.py DELETED
@@ -1,72 +0,0 @@
1
- from functools import lru_cache
2
- from typing import Literal
3
-
4
- from pydantic import Field, model_validator
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: Literal["openai", "gemini", "openrouter", "deterministic"] = "openai"
17
- openai_api_key: str = ""
18
- openai_model: str = Field(
19
- default="gpt-5.5", pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"
20
- )
21
- gemini_api_key: str = ""
22
- gemini_model: str = Field(
23
- default="gemini-2.5-pro", pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"
24
- )
25
- openrouter_api_key: str = ""
26
- openrouter_model: str = Field(
27
- default="openai/gpt-5.5", pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"
28
- )
29
- n8n_base_url: str = ""
30
- n8n_api_key: str = ""
31
- rate_limit_per_minute: int = Field(default=120, ge=10, le=10_000)
32
- auth_required: bool = False
33
-
34
- model_config = SettingsConfigDict(
35
- env_file=(".env", "../../.env"),
36
- env_file_encoding="utf-8",
37
- extra="ignore",
38
- )
39
-
40
- @model_validator(mode="after")
41
- def validate_production_configuration(self) -> "Settings":
42
- if self.environment != "production":
43
- return self
44
- missing = []
45
- if not self.auth_required:
46
- missing.append("AUTH_REQUIRED=true")
47
- if not self.supabase_url:
48
- missing.append("SUPABASE_URL")
49
- if not self.supabase_service_role_key:
50
- missing.append("SUPABASE_SERVICE_ROLE_KEY")
51
- provider_keys = {
52
- "openai": (self.openai_api_key, "OPENAI_API_KEY"),
53
- "gemini": (self.gemini_api_key, "GEMINI_API_KEY"),
54
- "openrouter": (self.openrouter_api_key, "OPENROUTER_API_KEY"),
55
- }
56
- provider_key = provider_keys.get(self.ai_provider)
57
- if provider_key and not provider_key[0]:
58
- missing.append(provider_key[1])
59
- if missing:
60
- raise ValueError(
61
- "Production configuration is incomplete: " + ", ".join(missing)
62
- )
63
- return self
64
-
65
- @property
66
- def allowed_origins(self) -> list[str]:
67
- return [origin.strip() for origin in self.frontend_url.split(",") if origin.strip()]
68
-
69
-
70
- @lru_cache
71
- def get_settings() -> Settings:
72
- return Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/errors.py DELETED
@@ -1,3 +0,0 @@
1
- class ServiceConfigurationError(RuntimeError):
2
- """Raised when an operation requires an unconfigured external service."""
3
-
 
 
 
 
app/core/rate_limit.py DELETED
@@ -1,45 +0,0 @@
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 DELETED
@@ -1,65 +0,0 @@
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 DELETED
@@ -1,36 +0,0 @@
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 DELETED
Binary file (2.51 kB)
 
app/models/__pycache__/workflow.cpython-314.pyc DELETED
Binary file (42.2 kB)
 
app/models/catalog.py DELETED
@@ -1,67 +0,0 @@
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
35
-
36
-
37
- class DashboardUser(BaseModel):
38
- name: str
39
- email: str | None = None
40
- avatar_url: str | None = None
41
-
42
-
43
- class DashboardStats(BaseModel):
44
- workflows: int = 0
45
- projects: int = 0
46
- active_workflows: int = 0
47
- executions: int = 0
48
- success_rate: float | None = None
49
- ai_generations: int = 0
50
-
51
-
52
- class DashboardWorkflow(BaseModel):
53
- id: str
54
- name: str
55
- project_name: str | None = None
56
- updated_at: str
57
- node_count: int = 0
58
- is_active: bool = False
59
- favorite: bool = False
60
-
61
-
62
- class DashboardSummary(BaseModel):
63
- workspace_name: str
64
- user: DashboardUser
65
- stats: DashboardStats
66
- workflows: list[DashboardWorkflow] = Field(default_factory=list)
67
- templates: list[TemplateSummary] = Field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models/workflow.py DELETED
@@ -1,465 +0,0 @@
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 CredentialReference(BaseModel):
13
- id: str | None = None
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, CredentialReference] | 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: Literal["openai", "gemini", "openrouter", "deterministic"] | None = None
91
- model: str | None = Field(
92
- default=None,
93
- min_length=1,
94
- max_length=200,
95
- pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$",
96
- )
97
-
98
-
99
- class GenerateWorkflowResponse(BaseModel):
100
- workflow: WorkflowDocument
101
- explanation: str
102
- warnings: list[str] = Field(default_factory=list)
103
-
104
-
105
- class WorkflowRequest(BaseModel):
106
- workflow: WorkflowDocument
107
-
108
-
109
- class ValidationIssue(BaseModel):
110
- code: str
111
- severity: Literal["error", "warning", "info"]
112
- message: str
113
- nodeId: str | None = None
114
- suggestion: str | None = None
115
-
116
-
117
- class ValidationResult(BaseModel):
118
- valid: bool
119
- score: int = Field(ge=0, le=100)
120
- issues: list[ValidationIssue]
121
-
122
-
123
- class OptimizationSuggestion(BaseModel):
124
- title: str
125
- description: str
126
- impact: Literal["low", "medium", "high"]
127
- nodeIds: list[str] = Field(default_factory=list)
128
-
129
-
130
- class OptimizationResponse(BaseModel):
131
- workflow: WorkflowDocument
132
- suggestions: list[OptimizationSuggestion]
133
-
134
-
135
- class ChatRequest(BaseModel):
136
- message: str = Field(min_length=1, max_length=20_000)
137
- workflow: WorkflowDocument
138
- conversation_id: str | None = None
139
-
140
-
141
- class ChatResponse(BaseModel):
142
- message: str
143
- workflow: WorkflowDocument | None = None
144
- actions: list[str] = Field(default_factory=list)
145
-
146
-
147
- class ExpressionRequest(BaseModel):
148
- description: str = Field(min_length=2, max_length=4000)
149
- context: dict[str, Any] = Field(default_factory=dict)
150
-
151
-
152
- class ExpressionResponse(BaseModel):
153
- expression: str
154
- explanation: str
155
- alternatives: list[str] = Field(default_factory=list)
156
-
157
-
158
- class ImportRequest(BaseModel):
159
- content: str = Field(min_length=2, max_length=5_000_000)
160
- source: Literal["json", "clipboard", "url", "github"] = "json"
161
-
162
-
163
- class ExportRequest(BaseModel):
164
- workflow: WorkflowDocument
165
- format: Literal["n8n", "internal"] = "n8n"
166
-
167
-
168
- class SaveRequest(BaseModel):
169
- workflow: WorkflowDocument
170
- project_id: str | None = None
171
- change_summary: str = Field(default="Manual save", max_length=500)
172
-
173
-
174
- class SaveResponse(BaseModel):
175
- id: str
176
- version: int
177
- saved_at: str
178
-
179
-
180
- class SimulationRequest(BaseModel):
181
- workflow: WorkflowDocument
182
- input_data: dict[str, Any] = Field(default_factory=dict)
183
-
184
-
185
- class NodeRunResult(BaseModel):
186
- node_id: str
187
- node_name: str
188
- status: Literal["success", "skipped", "error"]
189
- duration_ms: int = Field(ge=0)
190
- input_data: dict[str, Any] = Field(default_factory=dict)
191
- output_data: dict[str, Any] = Field(default_factory=dict)
192
- error: str | None = None
193
-
194
-
195
- class SimulationResponse(BaseModel):
196
- status: Literal["success", "error"]
197
- duration_ms: int = Field(ge=0)
198
- trace: list[NodeRunResult]
199
- output_data: dict[str, Any] = Field(default_factory=dict)
200
- warnings: list[str] = Field(default_factory=list)
201
-
202
-
203
- class TestAssertion(BaseModel):
204
- path: str = Field(min_length=1, max_length=500)
205
- operator: Literal["equals", "not_equals", "exists", "contains"] = "equals"
206
- expected: Any = None
207
-
208
-
209
- class WorkflowTestCase(BaseModel):
210
- name: str = Field(min_length=1, max_length=160)
211
- input_data: dict[str, Any] = Field(default_factory=dict)
212
- assertions: list[TestAssertion] = Field(default_factory=list, max_length=100)
213
-
214
-
215
- class TestWorkflowRequest(BaseModel):
216
- workflow: WorkflowDocument
217
- cases: list[WorkflowTestCase] = Field(min_length=1, max_length=100)
218
-
219
-
220
- class TestCaseResult(BaseModel):
221
- name: str
222
- passed: bool
223
- failures: list[str] = Field(default_factory=list)
224
- duration_ms: int = Field(ge=0)
225
-
226
-
227
- class TestWorkflowResponse(BaseModel):
228
- passed: int
229
- failed: int
230
- results: list[TestCaseResult]
231
-
232
-
233
- class CostEstimate(BaseModel):
234
- executions_per_month: int
235
- estimated_api_calls: int
236
- estimated_ai_tokens: int
237
- estimated_monthly_usd: float
238
- assumptions: list[str]
239
- rate_limit_warnings: list[str] = Field(default_factory=list)
240
-
241
-
242
- class CostEstimateRequest(BaseModel):
243
- workflow: WorkflowDocument
244
- executions_per_month: int = Field(default=1000, ge=1, le=100_000_000)
245
-
246
-
247
- class WorkflowDiffRequest(BaseModel):
248
- before: WorkflowDocument
249
- after: WorkflowDocument
250
-
251
-
252
- class WorkflowDiff(BaseModel):
253
- added_nodes: list[str] = Field(default_factory=list)
254
- removed_nodes: list[str] = Field(default_factory=list)
255
- modified_nodes: list[str] = Field(default_factory=list)
256
- moved_nodes: list[str] = Field(default_factory=list)
257
- added_edges: int = 0
258
- removed_edges: int = 0
259
-
260
-
261
- class ShareRequest(BaseModel):
262
- workflow_id: str
263
- permission: Literal["view", "copy"] = "view"
264
- expires_in_days: int | None = Field(default=30, ge=1, le=365)
265
-
266
-
267
- class ShareResponse(BaseModel):
268
- id: str
269
- url: str
270
- permission: Literal["view", "copy"]
271
- expires_at: datetime | None = None
272
-
273
-
274
- class SharedWorkflowResponse(BaseModel):
275
- workflow: WorkflowDocument
276
- permission: Literal["view", "copy"]
277
- expires_at: datetime | None = None
278
-
279
-
280
- class VersionSummary(BaseModel):
281
- id: str
282
- version: int
283
- change_summary: str | None = None
284
- created_at: str
285
- created_by: str
286
-
287
-
288
- class RestoreVersionRequest(BaseModel):
289
- version: int = Field(ge=1)
290
-
291
-
292
- class CommentRequest(BaseModel):
293
- workflow_id: str
294
- body: str = Field(min_length=1, max_length=10_000)
295
- node_id: str | None = Field(default=None, max_length=128)
296
-
297
-
298
- class WorkflowComment(BaseModel):
299
- id: str
300
- workflow_id: str
301
- user_id: str
302
- node_id: str | None = None
303
- body: str
304
- resolved_at: str | None = None
305
- created_at: str
306
-
307
-
308
- class DeploymentRequest(BaseModel):
309
- workflow: WorkflowDocument
310
- activate: bool = False
311
-
312
-
313
- class DeploymentResponse(BaseModel):
314
- status: Literal["deployed", "preview"]
315
- remote_workflow_id: str | None = None
316
- message: str
317
-
318
-
319
- class LineageField(BaseModel):
320
- field: str
321
- source_nodes: list[str] = Field(default_factory=list)
322
- consumer_nodes: list[str] = Field(default_factory=list)
323
- classification: Literal["public", "internal", "personal", "financial", "secret"]
324
-
325
-
326
- class LineageResponse(BaseModel):
327
- fields: list[LineageField]
328
- node_dependencies: dict[str, list[str]]
329
- sensitive_paths: list[str] = Field(default_factory=list)
330
-
331
-
332
- class ContractRequest(BaseModel):
333
- workflow: WorkflowDocument
334
- sample_data: dict[str, Any] = Field(default_factory=dict)
335
- expected_schema: dict[str, Literal["string", "number", "boolean", "object", "array", "null"]]
336
-
337
-
338
- class ContractResponse(BaseModel):
339
- valid: bool
340
- inferred_schema: dict[str, str]
341
- violations: list[str] = Field(default_factory=list)
342
-
343
-
344
- class QualityResponse(BaseModel):
345
- overall: int = Field(ge=0, le=100)
346
- scores: dict[str, int]
347
- findings: list[ValidationIssue]
348
-
349
-
350
- class IntentDriftRequest(BaseModel):
351
- workflow: WorkflowDocument
352
- requirement: str = Field(min_length=10, max_length=20_000)
353
-
354
-
355
- class IntentDriftResponse(BaseModel):
356
- alignment_score: int = Field(ge=0, le=100)
357
- covered_terms: list[str]
358
- missing_terms: list[str]
359
-
360
-
361
- class ReplayRequest(BaseModel):
362
- workflow: WorkflowDocument
363
- node_id: str = Field(min_length=1, max_length=128)
364
- input_data: dict[str, Any] = Field(default_factory=dict)
365
-
366
-
367
- class EnvironmentPromotionRequest(BaseModel):
368
- workflow: WorkflowDocument
369
- environment: Literal["development", "staging", "production"]
370
- values: dict[str, str | int | float | bool] = Field(default_factory=dict)
371
-
372
-
373
- class EnvironmentPromotionResponse(BaseModel):
374
- workflow: WorkflowDocument
375
- environment: str
376
- replacements: int
377
- unresolved: list[str] = Field(default_factory=list)
378
-
379
-
380
- class ReleasePlanRequest(BaseModel):
381
- workflow: WorkflowDocument
382
- strategy: Literal["shadow", "canary", "synthetic"]
383
- traffic_percentage: int = Field(default=10, ge=0, le=100)
384
- success_threshold: float = Field(default=0.99, ge=0, le=1)
385
- max_error_rate: float = Field(default=0.02, ge=0, le=1)
386
-
387
-
388
- class ReleasePlanResponse(BaseModel):
389
- strategy: str
390
- status: Literal["draft", "blocked"]
391
- requires_approval: bool = True
392
- steps: list[str]
393
- rollback_conditions: list[str]
394
- warnings: list[str] = Field(default_factory=list)
395
-
396
-
397
- class WorkflowPackageRequest(BaseModel):
398
- workflow: WorkflowDocument
399
- tests: list[WorkflowTestCase] = Field(default_factory=list)
400
- contracts: dict[str, Any] = Field(default_factory=dict)
401
- environments: dict[str, dict[str, Any]] = Field(default_factory=dict)
402
-
403
-
404
- class WorkflowPackageResponse(BaseModel):
405
- manifest: dict[str, Any]
406
- workflow: WorkflowDocument
407
- tests: list[WorkflowTestCase]
408
- contracts: dict[str, Any]
409
- environments: dict[str, dict[str, Any]]
410
-
411
-
412
- class DocumentationResponse(BaseModel):
413
- markdown: str
414
-
415
-
416
- class RoiRequest(BaseModel):
417
- workflow: WorkflowDocument
418
- executions_per_month: int = Field(default=1000, ge=1, le=100_000_000)
419
- minutes_saved_per_execution: float = Field(default=5, ge=0, le=100_000)
420
- hourly_rate_usd: float = Field(default=30, ge=0, le=100_000)
421
- sla_minutes: float = Field(default=60, gt=0, le=1_000_000)
422
-
423
-
424
- class RoiResponse(BaseModel):
425
- hours_saved: float
426
- labor_value_usd: float
427
- estimated_operating_cost_usd: float
428
- net_value_usd: float
429
- estimated_duration_ms: int
430
- sla_headroom_percent: float
431
-
432
-
433
- class WebhookInspectRequest(BaseModel):
434
- payload: dict[str, Any]
435
- redact: bool = True
436
-
437
-
438
- class WebhookInspectResponse(BaseModel):
439
- payload: dict[str, Any]
440
- schema_map: dict[str, str]
441
- redacted_fields: list[str]
442
-
443
-
444
- class DependencyImpactRequest(BaseModel):
445
- workflow: WorkflowDocument
446
- dependency: str = Field(min_length=1, max_length=500)
447
-
448
-
449
- class DependencyImpactResponse(BaseModel):
450
- affected_nodes: list[str]
451
- downstream_nodes: list[str]
452
- severity: Literal["none", "low", "medium", "high"]
453
-
454
-
455
- class SelfHealRequest(BaseModel):
456
- workflow: WorkflowDocument
457
- errors: list[str] = Field(default_factory=list, max_length=100)
458
-
459
-
460
- class SelfHealResponse(BaseModel):
461
- proposed_workflow: WorkflowDocument
462
- changes: list[str]
463
- quality_before: int
464
- quality_after: int
465
- requires_approval: bool = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/__pycache__/adapters.cpython-314.pyc DELETED
Binary file (12 kB)
 
app/services/__pycache__/catalog.cpython-314.pyc DELETED
Binary file (4.02 kB)
 
app/services/__pycache__/chat.cpython-314.pyc DELETED
Binary file (4.85 kB)
 
app/services/__pycache__/collaboration.cpython-314.pyc DELETED
Binary file (19.1 kB)
 
app/services/__pycache__/deployment.cpython-314.pyc DELETED
Binary file (3.09 kB)
 
app/services/__pycache__/generator.cpython-314.pyc DELETED
Binary file (11.1 kB)
 
app/services/__pycache__/intelligence.cpython-314.pyc DELETED
Binary file (25.6 kB)
 
app/services/__pycache__/operations.cpython-314.pyc DELETED
Binary file (14.9 kB)
 
app/services/__pycache__/optimizer.cpython-314.pyc DELETED
Binary file (4.65 kB)
 
app/services/__pycache__/remote.cpython-314.pyc DELETED
Binary file (4.53 kB)
 
app/services/__pycache__/repository.cpython-314.pyc DELETED
Binary file (9.53 kB)
 
app/services/__pycache__/validation.cpython-314.pyc DELETED
Binary file (15.4 kB)
 
app/services/adapters.py DELETED
@@ -1,178 +0,0 @@
1
- import json
2
- from typing import Any, Protocol
3
-
4
- from app.models.workflow import (
5
- CredentialReference,
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: CredentialReference(
56
- id=str(value["id"]) if value.get("id") else None,
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: {
144
- **({"id": value.id} if value.id else {}),
145
- "name": value.name,
146
- }
147
- for key, value in (node.data.credentials or {}).items()
148
- },
149
- **({"disabled": True} if node.data.disabled else {}),
150
- }
151
- for node in workflow.nodes
152
- ],
153
- "connections": connections,
154
- "settings": workflow.settings,
155
- "staticData": None,
156
- "meta": workflow.meta.model_dump(exclude_none=True),
157
- "pinData": workflow.pinData,
158
- "tags": [{"name": tag} for tag in workflow.meta.tags],
159
- }
160
-
161
-
162
- class AdapterRegistry:
163
- def __init__(self) -> None:
164
- self.importers: dict[str, ImportAdapter] = {}
165
- self.exporters: dict[str, ExportAdapter] = {}
166
-
167
- def register_importer(self, name: str, adapter: ImportAdapter) -> None:
168
- self.importers[name] = adapter
169
-
170
- def register_exporter(self, name: str, adapter: ExportAdapter) -> None:
171
- self.exporters[name] = adapter
172
-
173
-
174
- adapters = AdapterRegistry()
175
- n8n_adapter = N8nJsonAdapter()
176
- adapters.register_importer("json", n8n_adapter)
177
- adapters.register_importer("clipboard", n8n_adapter)
178
- adapters.register_exporter("n8n", n8n_adapter)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/catalog.py DELETED
@@ -1,131 +0,0 @@
1
- from app.models.catalog import NodeDefinition
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-5.5"},
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}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/chat.py DELETED
@@ -1,84 +0,0 @@
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 DELETED
@@ -1,248 +0,0 @@
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.core.errors import ServiceConfigurationError
10
- from app.models.workflow import (
11
- ShareRequest,
12
- ShareResponse,
13
- SharedWorkflowResponse,
14
- VersionSummary,
15
- WorkflowComment,
16
- WorkflowDocument,
17
- )
18
-
19
-
20
- class CollaborationRepository:
21
- def __init__(self, settings: Settings):
22
- self.settings = settings
23
-
24
- @property
25
- def configured(self) -> bool:
26
- return bool(self.settings.supabase_url and self.settings.supabase_service_role_key)
27
-
28
- @property
29
- def base_url(self) -> str:
30
- return f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
31
-
32
- def _headers(self, prefer: str = "return=representation") -> dict[str, str]:
33
- return {
34
- "apikey": self.settings.supabase_service_role_key,
35
- "Authorization": f"Bearer {self.settings.supabase_service_role_key}",
36
- "Content-Type": "application/json",
37
- "Prefer": prefer,
38
- }
39
-
40
- def _require_configured(self) -> None:
41
- if not self.configured:
42
- raise ServiceConfigurationError(
43
- "Supabase persistence is not configured on the API server."
44
- )
45
-
46
- async def _workspace_access(
47
- self, client: httpx.AsyncClient, user_id: str, *, edit: bool
48
- ) -> set[str]:
49
- response = await client.get(
50
- f"{self.base_url}/workspace_members?select=workspace_id,role&user_id=eq.{user_id}",
51
- headers=self._headers(),
52
- )
53
- response.raise_for_status()
54
- allowed = {"owner", "admin", "editor"} if edit else {"owner", "admin", "editor", "viewer"}
55
- return {row["workspace_id"] for row in response.json() if row["role"] in allowed}
56
-
57
- async def _authorized_workflow(
58
- self,
59
- client: httpx.AsyncClient,
60
- workflow_id: str,
61
- user_id: str,
62
- *,
63
- edit: bool,
64
- ) -> dict:
65
- workspace_ids = await self._workspace_access(client, user_id, edit=edit)
66
- response = await client.get(
67
- f"{self.base_url}/workflows?select=id,workspace_id,definition&id=eq.{workflow_id}&limit=1",
68
- headers=self._headers(),
69
- )
70
- response.raise_for_status()
71
- rows = response.json()
72
- if not rows or rows[0]["workspace_id"] not in workspace_ids:
73
- raise ValueError("Workflow is not accessible by this user")
74
- return rows[0]
75
-
76
- async def authorize_deploy(self, workflow_id: str, user_id: str) -> bool:
77
- if not self.configured:
78
- return False
79
- async with httpx.AsyncClient(timeout=10) as client:
80
- response = await client.get(
81
- (
82
- f"{self.base_url}/workflows?select=workspace_id"
83
- f"&id=eq.{workflow_id}&limit=1"
84
- ),
85
- headers=self._headers(),
86
- )
87
- response.raise_for_status()
88
- workflows = response.json()
89
- if not workflows:
90
- raise ValueError("Workflow must be saved before deployment")
91
- membership = await client.get(
92
- (
93
- f"{self.base_url}/workspace_members?select=role"
94
- f"&workspace_id=eq.{workflows[0]['workspace_id']}"
95
- f"&user_id=eq.{user_id}&limit=1"
96
- ),
97
- headers=self._headers(),
98
- )
99
- membership.raise_for_status()
100
- rows = membership.json()
101
- if not rows or rows[0]["role"] not in {"owner", "admin"}:
102
- raise ValueError("Only workspace owners and admins can deploy workflows")
103
- return True
104
-
105
- async def create_share(self, request: ShareRequest, user_id: str) -> ShareResponse:
106
- self._require_configured()
107
- token = secrets.token_urlsafe(32)
108
- token_hash = hashlib.sha256(token.encode()).hexdigest()
109
- expires_at = (
110
- datetime.now(UTC) + timedelta(days=request.expires_in_days)
111
- if request.expires_in_days
112
- else None
113
- )
114
- share_id = str(uuid4())
115
- async with httpx.AsyncClient(timeout=10) as client:
116
- await self._authorized_workflow(
117
- client, request.workflow_id, user_id, edit=True
118
- )
119
- response = await client.post(
120
- f"{self.base_url}/workflow_shares",
121
- headers=self._headers(),
122
- json={
123
- "id": share_id,
124
- "workflow_id": request.workflow_id,
125
- "created_by": user_id,
126
- "token_hash": token_hash,
127
- "permission": request.permission,
128
- "expires_at": expires_at.isoformat() if expires_at else None,
129
- },
130
- )
131
- response.raise_for_status()
132
- return ShareResponse(
133
- id=share_id,
134
- url=f"{self.settings.allowed_origins[0].rstrip('/')}/share/{token}",
135
- permission=request.permission,
136
- expires_at=expires_at,
137
- )
138
-
139
- async def shared_workflow(self, token: str) -> SharedWorkflowResponse:
140
- self._require_configured()
141
- token_hash = hashlib.sha256(token.encode()).hexdigest()
142
- async with httpx.AsyncClient(timeout=10) as client:
143
- response = await client.get(
144
- (
145
- f"{self.base_url}/workflow_shares"
146
- "?select=permission,expires_at,revoked_at,workflow:workflows(definition)"
147
- f"&token_hash=eq.{token_hash}&limit=1"
148
- ),
149
- headers=self._headers(),
150
- )
151
- response.raise_for_status()
152
- rows = response.json()
153
- if not rows or rows[0]["revoked_at"]:
154
- raise ValueError("Share link was not found")
155
- row = rows[0]
156
- shared = SharedWorkflowResponse(
157
- workflow=WorkflowDocument.model_validate(row["workflow"]["definition"]),
158
- permission=row["permission"],
159
- expires_at=row["expires_at"],
160
- )
161
- if shared.expires_at and shared.expires_at < datetime.now(UTC):
162
- raise ValueError("Share link has expired")
163
- return shared
164
-
165
- async def versions(self, workflow_id: str, user_id: str) -> list[VersionSummary]:
166
- self._require_configured()
167
- async with httpx.AsyncClient(timeout=10) as client:
168
- await self._authorized_workflow(client, workflow_id, user_id, edit=False)
169
- response = await client.get(
170
- (
171
- f"{self.base_url}/workflow_versions"
172
- "?select=id,version_number,change_summary,created_at,created_by"
173
- f"&workflow_id=eq.{workflow_id}&order=version_number.desc&limit=100"
174
- ),
175
- headers=self._headers(),
176
- )
177
- response.raise_for_status()
178
- return [
179
- VersionSummary(
180
- id=row["id"],
181
- version=row["version_number"],
182
- change_summary=row["change_summary"],
183
- created_at=row["created_at"],
184
- created_by=row["created_by"],
185
- )
186
- for row in response.json()
187
- ]
188
-
189
- async def restore(self, workflow_id: str, version: int, user_id: str) -> WorkflowDocument:
190
- self._require_configured()
191
- async with httpx.AsyncClient(timeout=10) as client:
192
- await self._authorized_workflow(client, workflow_id, user_id, edit=True)
193
- response = await client.get(
194
- (
195
- f"{self.base_url}/workflow_versions?select=definition"
196
- f"&workflow_id=eq.{workflow_id}&version_number=eq.{version}&limit=1"
197
- ),
198
- headers=self._headers(),
199
- )
200
- response.raise_for_status()
201
- rows = response.json()
202
- if not rows:
203
- raise ValueError("Workflow version was not found")
204
- definition = rows[0]["definition"]
205
- definition["id"] = workflow_id
206
- update = await client.patch(
207
- f"{self.base_url}/workflows?id=eq.{workflow_id}",
208
- headers=self._headers(),
209
- json={"definition": definition, "name": definition["name"]},
210
- )
211
- update.raise_for_status()
212
- history = await client.post(
213
- f"{self.base_url}/workflow_versions",
214
- headers=self._headers(),
215
- json={
216
- "workflow_id": workflow_id,
217
- "created_by": user_id,
218
- "definition": definition,
219
- "change_summary": f"Restored version {version}",
220
- },
221
- )
222
- history.raise_for_status()
223
- return WorkflowDocument.model_validate(definition)
224
-
225
- async def comments(self, workflow_id: str, user_id: str) -> list[WorkflowComment]:
226
- self._require_configured()
227
- async with httpx.AsyncClient(timeout=10) as client:
228
- await self._authorized_workflow(client, workflow_id, user_id, edit=False)
229
- response = await client.get(
230
- f"{self.base_url}/workflow_comments?select=*&workflow_id=eq.{workflow_id}&order=created_at.desc",
231
- headers=self._headers(),
232
- )
233
- response.raise_for_status()
234
- return [WorkflowComment(**row) for row in response.json()]
235
-
236
- async def add_comment(
237
- self, workflow_id: str, body: str, node_id: str | None, user_id: str
238
- ) -> WorkflowComment:
239
- self._require_configured()
240
- async with httpx.AsyncClient(timeout=10) as client:
241
- await self._authorized_workflow(client, workflow_id, user_id, edit=False)
242
- response = await client.post(
243
- f"{self.base_url}/workflow_comments",
244
- headers=self._headers(),
245
- json={"workflow_id": workflow_id, "user_id": user_id, "node_id": node_id, "body": body},
246
- )
247
- response.raise_for_status()
248
- return WorkflowComment(**response.json()[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/deployment.py DELETED
@@ -1,43 +0,0 @@
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 DELETED
@@ -1,411 +0,0 @@
1
- import json
2
- import re
3
- from dataclasses import dataclass
4
- from typing import Protocol
5
-
6
- import httpx
7
-
8
- from app.core.config import get_settings
9
- from app.models.workflow import (
10
- CredentialReference,
11
- GenerateWorkflowResponse,
12
- Position,
13
- WorkflowDocument,
14
- WorkflowEdge,
15
- WorkflowMeta,
16
- WorkflowNode,
17
- WorkflowNodeData,
18
- )
19
-
20
- WORKFLOW_SYSTEM_PROMPT = (
21
- "You generate production-ready n8n workflow documents for FlowForge. "
22
- "Return only valid JSON with keys workflow, explanation, and warnings. "
23
- "The workflow must contain name, active, nodes, edges, settings, and meta. "
24
- "Each node must contain id, position {x,y}, and data with label, type, "
25
- "typeVersion, category, parameters, and optional credentials. "
26
- "Credential objects must include name and type and omit credential IDs. "
27
- "Use only these categories: trigger, core, ai, database, communication, cloud, developer. "
28
- "Use n8n expressions only when they start with = and have balanced {{ }} braces. "
29
- "Never invent secret values, API keys, private URLs, or credential IDs. "
30
- "For an unspecified endpoint use an n8n environment expression such as "
31
- "={{ $env.SERVICE_BASE_URL }} and mention it in warnings."
32
- )
33
-
34
-
35
- def _parse_provider_response(content: str, provider: str, model: str) -> GenerateWorkflowResponse:
36
- try:
37
- result = GenerateWorkflowResponse.model_validate(json.loads(content))
38
- except (TypeError, json.JSONDecodeError, ValueError) as exc:
39
- raise ValueError(f"{provider} returned an invalid workflow document") from exc
40
- result.workflow.meta.generatedBy = f"{provider} {model}"
41
- return result
42
-
43
-
44
- class WorkflowProvider(Protocol):
45
- async def generate(self, prompt: str, model: str | None = None) -> GenerateWorkflowResponse: ...
46
-
47
-
48
- @dataclass(slots=True)
49
- class PlannedNode:
50
- key: str
51
- label: str
52
- node_type: str
53
- category: str
54
- subtitle: str
55
- parameters: dict
56
- credentials: list[str]
57
-
58
-
59
- def _credential_map(types: list[str], label: str) -> dict[str, CredentialReference] | None:
60
- if not types:
61
- return None
62
- return {
63
- credential_type: CredentialReference(
64
- name=f"Connect {label}", type=credential_type
65
- )
66
- for credential_type in types
67
- }
68
-
69
-
70
- class RuleBasedWorkflowProvider:
71
- """CPU-friendly baseline provider used as a fallback and in tests."""
72
-
73
- async def generate(self, prompt: str, model: str | None = None) -> GenerateWorkflowResponse:
74
- text = prompt.lower()
75
- plan: list[PlannedNode] = []
76
-
77
- if "gmail" in text or "email" in text:
78
- plan.append(
79
- PlannedNode(
80
- "gmail-trigger",
81
- "Gmail Trigger",
82
- "n8n-nodes-base.gmailTrigger",
83
- "trigger",
84
- "New matching email",
85
- {"filters": {"hasAttachment": "attachment" in text}},
86
- ["gmailOAuth2"],
87
- )
88
- )
89
- elif any(term in text for term in ("schedule", "daily", "hourly", "weekly")):
90
- plan.append(
91
- PlannedNode(
92
- "schedule-trigger",
93
- "Schedule Trigger",
94
- "n8n-nodes-base.scheduleTrigger",
95
- "trigger",
96
- "Every hour",
97
- {"rule": {"interval": [{"field": "hours", "hoursInterval": 1}]}},
98
- [],
99
- )
100
- )
101
- else:
102
- plan.append(
103
- PlannedNode(
104
- "webhook",
105
- "Webhook",
106
- "n8n-nodes-base.webhook",
107
- "trigger",
108
- "POST /flowforge-webhook",
109
- {"httpMethod": "POST", "path": "flowforge-webhook"},
110
- [],
111
- )
112
- )
113
-
114
- if any(term in text for term in ("extract", "classify", "summar", "invoice", " ai ")):
115
- plan.append(
116
- PlannedNode(
117
- "ai-extract",
118
- "Extract Structured Data",
119
- "@n8n/n8n-nodes-langchain.informationExtractor",
120
- "ai",
121
- "AI structured output",
122
- {
123
- "text": "={{ $binary.data || $json.text || $json.body }}",
124
- "schemaType": "manual",
125
- "inputSchema": (
126
- '{"invoice_number":"string","vendor":"string",'
127
- '"amount":"number","due_date":"string"}'
128
- if "invoice" in text
129
- else '{"result":"string","confidence":"number"}'
130
- ),
131
- },
132
- ["openAiApi"],
133
- )
134
- )
135
-
136
- if "http" in text or " api" in text:
137
- plan.append(
138
- PlannedNode(
139
- "http-request",
140
- "HTTP Request",
141
- "n8n-nodes-base.httpRequest",
142
- "core",
143
- "Call external API",
144
- {
145
- "method": "GET",
146
- "url": "={{ $env.API_BASE_URL }}",
147
- "options": {"timeout": 30000},
148
- },
149
- [],
150
- )
151
- )
152
-
153
- if "supabase" in text:
154
- plan.append(
155
- PlannedNode(
156
- "supabase",
157
- "Store in Supabase",
158
- "n8n-nodes-base.supabase",
159
- "database",
160
- "Insert record",
161
- {
162
- "operation": "create",
163
- "tableId": "invoices" if "invoice" in text else "records",
164
- "fieldsUi": {"fieldValues": []},
165
- },
166
- ["supabaseApi"],
167
- )
168
- )
169
- elif "postgres" in text or "database" in text:
170
- plan.append(
171
- PlannedNode(
172
- "postgres",
173
- "Save to Postgres",
174
- "n8n-nodes-base.postgres",
175
- "database",
176
- "Insert record",
177
- {
178
- "operation": "executeQuery",
179
- "query": "INSERT INTO records (payload) VALUES ($1)",
180
- "options": {"queryReplacement": "={{ [$json] }}"},
181
- },
182
- ["postgres"],
183
- )
184
- )
185
-
186
- if "slack" in text or "notification" in text or "notify" in text:
187
- plan.append(
188
- PlannedNode(
189
- "slack",
190
- "Send Slack Notification",
191
- "n8n-nodes-base.slack",
192
- "communication",
193
- "#automation",
194
- {
195
- "resource": "message",
196
- "operation": "send",
197
- "channel": "#automation",
198
- "text": "=Workflow completed for {{ $json.invoice_number || $json.id }}",
199
- },
200
- ["slackOAuth2Api"],
201
- )
202
- )
203
-
204
- if len(plan) == 1:
205
- plan.append(
206
- PlannedNode(
207
- "edit-fields",
208
- "Prepare Output",
209
- "n8n-nodes-base.set",
210
- "core",
211
- "Normalize response",
212
- {
213
- "assignments": {
214
- "assignments": [
215
- {
216
- "name": "status",
217
- "value": "completed",
218
- "type": "string",
219
- }
220
- ]
221
- }
222
- },
223
- [],
224
- )
225
- )
226
-
227
- nodes = [
228
- WorkflowNode(
229
- id=item.key,
230
- position=Position(x=80 + index * 315, y=190 + (index % 2) * 45),
231
- data=WorkflowNodeData(
232
- label=item.label,
233
- type=item.node_type,
234
- typeVersion=1,
235
- category=item.category, # type: ignore[arg-type]
236
- subtitle=item.subtitle,
237
- parameters=item.parameters,
238
- credentials=_credential_map(item.credentials, item.label),
239
- ),
240
- )
241
- for index, item in enumerate(plan)
242
- ]
243
- edges = [
244
- WorkflowEdge(
245
- id=f"{source.key}-{target.key}",
246
- source=source.key,
247
- target=target.key,
248
- animated=index == 0,
249
- )
250
- for index, (source, target) in enumerate(zip(plan, plan[1:]))
251
- ]
252
-
253
- title_words = re.findall(r"[a-zA-Z0-9]+", prompt)[:7]
254
- title = " ".join(title_words).strip().capitalize() or "Generated workflow"
255
- if len(title) > 62:
256
- title = f"{title[:59]}..."
257
-
258
- workflow = WorkflowDocument(
259
- name=title,
260
- nodes=nodes,
261
- edges=edges,
262
- settings={
263
- "executionOrder": "v1",
264
- "saveManualExecutions": True,
265
- "saveExecutionProgress": True,
266
- "errorWorkflow": "",
267
- "timezone": "UTC",
268
- },
269
- meta=WorkflowMeta(
270
- description=prompt[:500],
271
- generatedBy="FlowForge deterministic provider",
272
- version=1,
273
- tags=["AI generated"],
274
- ),
275
- )
276
- return GenerateWorkflowResponse(
277
- workflow=workflow,
278
- explanation=f"Created a {len(nodes)}-node workflow with a trigger and connected actions.",
279
- warnings=[
280
- "Select credentials for each connected service before activation.",
281
- "Review generated expressions with representative execution data.",
282
- ],
283
- )
284
-
285
-
286
- class OpenAIWorkflowProvider:
287
- """Generate normalized workflow documents through OpenAI structured JSON output."""
288
-
289
- async def generate(self, prompt: str, model: str | None = None) -> GenerateWorkflowResponse:
290
- settings = get_settings()
291
- if not settings.openai_api_key:
292
- raise RuntimeError("OPENAI_API_KEY is required when AI_PROVIDER=openai.")
293
- selected_model = model or settings.openai_model
294
- payload = {
295
- "model": selected_model,
296
- "messages": [
297
- {"role": "system", "content": WORKFLOW_SYSTEM_PROMPT},
298
- {"role": "user", "content": prompt},
299
- ],
300
- "response_format": {"type": "json_object"},
301
- "max_completion_tokens": 12000,
302
- }
303
- async with httpx.AsyncClient(timeout=60) as client:
304
- response = await client.post(
305
- "https://api.openai.com/v1/chat/completions",
306
- headers={
307
- "Authorization": f"Bearer {settings.openai_api_key}",
308
- "Content-Type": "application/json",
309
- },
310
- json=payload,
311
- )
312
- response.raise_for_status()
313
- try:
314
- content = response.json()["choices"][0]["message"]["content"]
315
- except (KeyError, TypeError, IndexError) as exc:
316
- raise ValueError("OpenAI returned an invalid response envelope") from exc
317
- return _parse_provider_response(content, "OpenAI", selected_model)
318
-
319
-
320
- class GeminiWorkflowProvider:
321
- """Generate workflow documents through the Gemini generateContent API."""
322
-
323
- async def generate(self, prompt: str, model: str | None = None) -> GenerateWorkflowResponse:
324
- settings = get_settings()
325
- if not settings.gemini_api_key:
326
- raise RuntimeError("GEMINI_API_KEY is required when AI_PROVIDER=gemini.")
327
- selected_model = model or settings.gemini_model
328
- payload = {
329
- "systemInstruction": {"parts": [{"text": WORKFLOW_SYSTEM_PROMPT}]},
330
- "contents": [{"role": "user", "parts": [{"text": prompt}]}],
331
- "generationConfig": {
332
- "responseMimeType": "application/json",
333
- "temperature": 0.2,
334
- "maxOutputTokens": 12000,
335
- },
336
- }
337
- async with httpx.AsyncClient(timeout=60) as client:
338
- response = await client.post(
339
- f"https://generativelanguage.googleapis.com/v1beta/models/{selected_model}:generateContent",
340
- headers={
341
- "x-goog-api-key": settings.gemini_api_key,
342
- "Content-Type": "application/json",
343
- },
344
- json=payload,
345
- )
346
- response.raise_for_status()
347
- try:
348
- parts = response.json()["candidates"][0]["content"]["parts"]
349
- content = "".join(part.get("text", "") for part in parts)
350
- except (KeyError, TypeError, IndexError) as exc:
351
- raise ValueError("Gemini returned an invalid response envelope") from exc
352
- return _parse_provider_response(content, "Gemini", selected_model)
353
-
354
-
355
- class OpenRouterWorkflowProvider:
356
- """Generate workflow documents through OpenRouter's chat completions API."""
357
-
358
- async def generate(self, prompt: str, model: str | None = None) -> GenerateWorkflowResponse:
359
- settings = get_settings()
360
- if not settings.openrouter_api_key:
361
- raise RuntimeError(
362
- "OPENROUTER_API_KEY is required when AI_PROVIDER=openrouter."
363
- )
364
- selected_model = model or settings.openrouter_model
365
- payload = {
366
- "model": selected_model,
367
- "messages": [
368
- {"role": "system", "content": WORKFLOW_SYSTEM_PROMPT},
369
- {"role": "user", "content": prompt},
370
- ],
371
- "response_format": {"type": "json_object"},
372
- "max_tokens": 12000,
373
- }
374
- async with httpx.AsyncClient(timeout=60) as client:
375
- response = await client.post(
376
- "https://openrouter.ai/api/v1/chat/completions",
377
- headers={
378
- "Authorization": f"Bearer {settings.openrouter_api_key}",
379
- "Content-Type": "application/json",
380
- "HTTP-Referer": settings.allowed_origins[0],
381
- "X-Title": settings.app_name,
382
- },
383
- json=payload,
384
- )
385
- response.raise_for_status()
386
- try:
387
- content = response.json()["choices"][0]["message"]["content"]
388
- except (KeyError, TypeError, IndexError) as exc:
389
- raise ValueError("OpenRouter returned an invalid response envelope") from exc
390
- return _parse_provider_response(content, "OpenRouter", selected_model)
391
-
392
-
393
- class ProviderRegistry:
394
- def __init__(self) -> None:
395
- self._providers: dict[str, WorkflowProvider] = {}
396
-
397
- def register(self, name: str, provider: WorkflowProvider) -> None:
398
- self._providers[name] = provider
399
-
400
- def get(self, name: str) -> WorkflowProvider:
401
- provider = self._providers.get(name)
402
- if not provider:
403
- raise ValueError(f"Unsupported AI provider: {name}")
404
- return provider
405
-
406
-
407
- providers = ProviderRegistry()
408
- providers.register("openai", OpenAIWorkflowProvider())
409
- providers.register("gemini", GeminiWorkflowProvider())
410
- providers.register("openrouter", OpenRouterWorkflowProvider())
411
- providers.register("deterministic", RuleBasedWorkflowProvider())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/intelligence.py DELETED
@@ -1,300 +0,0 @@
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 DELETED
@@ -1,230 +0,0 @@
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 DELETED
@@ -1,83 +0,0 @@
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 DELETED
@@ -1,66 +0,0 @@
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 DELETED
@@ -1,411 +0,0 @@
1
- import re
2
- from datetime import UTC, datetime
3
- from uuid import uuid4
4
-
5
- import httpx
6
-
7
- from app.core.config import Settings
8
- from app.core.errors import ServiceConfigurationError
9
- from app.models.catalog import (
10
- DashboardStats,
11
- DashboardSummary,
12
- DashboardUser,
13
- DashboardWorkflow,
14
- ProjectSummary,
15
- TemplateSummary,
16
- )
17
- from app.models.workflow import SaveRequest, SaveResponse, WorkflowDocument
18
-
19
-
20
- class WorkflowRepository:
21
- def __init__(self, settings: Settings):
22
- self.settings = settings
23
-
24
- @property
25
- def configured(self) -> bool:
26
- return bool(self.settings.supabase_url and self.settings.supabase_service_role_key)
27
-
28
- def _headers(self) -> dict[str, str]:
29
- return {
30
- "apikey": self.settings.supabase_service_role_key,
31
- "Authorization": f"Bearer {self.settings.supabase_service_role_key}",
32
- "Content-Type": "application/json",
33
- "Prefer": "return=representation",
34
- }
35
-
36
- def _require_configured(self) -> None:
37
- if not self.configured:
38
- raise ServiceConfigurationError(
39
- "Supabase persistence is not configured on the API server."
40
- )
41
-
42
- @property
43
- def base_url(self) -> str:
44
- return f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
45
-
46
- async def _workspace_ids(self, client: httpx.AsyncClient, user_id: str) -> list[str]:
47
- response = await client.get(
48
- f"{self.base_url}/workspace_members?select=workspace_id&user_id=eq.{user_id}",
49
- headers=self._headers(),
50
- )
51
- response.raise_for_status()
52
- return [row["workspace_id"] for row in response.json()]
53
-
54
- async def _count(self, client: httpx.AsyncClient, path: str) -> int:
55
- response = await client.get(
56
- f"{self.base_url}/{path}",
57
- headers={**self._headers(), "Prefer": "count=exact"},
58
- )
59
- response.raise_for_status()
60
- content_range = response.headers.get("content-range", "")
61
- try:
62
- return int(content_range.rsplit("/", 1)[1])
63
- except (IndexError, ValueError):
64
- return len(response.json())
65
-
66
- @staticmethod
67
- def _response_count(response: httpx.Response) -> int:
68
- content_range = response.headers.get("content-range", "")
69
- try:
70
- return int(content_range.rsplit("/", 1)[1])
71
- except (IndexError, ValueError):
72
- return len(response.json())
73
-
74
- async def save(self, request: SaveRequest, user_id: str) -> SaveResponse:
75
- self._require_configured()
76
- now = datetime.now(UTC).isoformat()
77
- workflow_id = str(uuid4())
78
- base_url = self.base_url
79
- async with httpx.AsyncClient(timeout=10) as client:
80
- membership_response = await client.get(
81
- (
82
- f"{base_url}/workspace_members"
83
- f"?select=workspace_id,role&user_id=eq.{user_id}"
84
- ),
85
- headers=self._headers(),
86
- )
87
- membership_response.raise_for_status()
88
- memberships = membership_response.json()
89
- editable_workspace_ids = {
90
- item["workspace_id"]
91
- for item in memberships
92
- if item["role"] in {"owner", "admin", "editor"}
93
- }
94
- if not editable_workspace_ids:
95
- raise ValueError("User does not have edit access to a workspace")
96
-
97
- owner_id = user_id
98
- workspace_id = next(iter(editable_workspace_ids))
99
- if request.workflow.id:
100
- existing_response = await client.get(
101
- (
102
- f"{base_url}/workflows"
103
- f"?select=id,owner_id,workspace_id&id=eq.{request.workflow.id}&limit=1"
104
- ),
105
- headers=self._headers(),
106
- )
107
- existing_response.raise_for_status()
108
- existing = existing_response.json()
109
- if existing:
110
- record = existing[0]
111
- if record["workspace_id"] not in editable_workspace_ids:
112
- raise ValueError("Workflow is not editable by this user")
113
- workflow_id = record["id"]
114
- workspace_id = record["workspace_id"]
115
- owner_id = record["owner_id"]
116
-
117
- if request.project_id:
118
- project_response = await client.get(
119
- (
120
- f"{base_url}/projects?select=workspace_id"
121
- f"&id=eq.{request.project_id}&limit=1"
122
- ),
123
- headers=self._headers(),
124
- )
125
- project_response.raise_for_status()
126
- projects = project_response.json()
127
- if (
128
- not projects
129
- or projects[0]["workspace_id"] not in editable_workspace_ids
130
- or (
131
- request.workflow.id
132
- and workflow_id == request.workflow.id
133
- and projects[0]["workspace_id"] != workspace_id
134
- )
135
- ):
136
- raise ValueError("Project is not editable by this user")
137
- workspace_id = projects[0]["workspace_id"]
138
-
139
- definition = request.workflow.model_dump(mode="json")
140
- definition["id"] = workflow_id
141
- payload = {
142
- "id": workflow_id,
143
- "owner_id": owner_id,
144
- "workspace_id": workspace_id,
145
- "project_id": request.project_id,
146
- "name": request.workflow.name,
147
- "definition": definition,
148
- "is_active": request.workflow.active,
149
- "updated_at": now,
150
- }
151
- response = await client.post(
152
- f"{base_url}/workflows?on_conflict=id",
153
- headers={**self._headers(), "Prefer": "resolution=merge-duplicates,return=representation"},
154
- json=payload,
155
- )
156
- response.raise_for_status()
157
- version_response = await client.post(
158
- f"{base_url}/workflow_versions",
159
- headers=self._headers(),
160
- json={
161
- "workflow_id": workflow_id,
162
- "created_by": user_id,
163
- "definition": definition,
164
- "change_summary": request.change_summary,
165
- },
166
- )
167
- version_response.raise_for_status()
168
- version_payload = version_response.json()[0]
169
- return SaveResponse(
170
- id=workflow_id,
171
- version=version_payload["version_number"],
172
- saved_at=version_payload["created_at"],
173
- )
174
-
175
- async def projects(self, user_id: str) -> list[ProjectSummary]:
176
- self._require_configured()
177
- base_url = self.base_url
178
- async with httpx.AsyncClient(timeout=10) as client:
179
- membership_response = await client.get(
180
- f"{base_url}/workspace_members?select=workspace_id&user_id=eq.{user_id}",
181
- headers=self._headers(),
182
- )
183
- membership_response.raise_for_status()
184
- workspace_ids = [
185
- item["workspace_id"] for item in membership_response.json()
186
- ]
187
- if not workspace_ids:
188
- return []
189
- workspace_filter = ",".join(workspace_ids)
190
- response = await client.get(
191
- (
192
- f"{base_url}/projects"
193
- "?select=id,name,description,updated_at"
194
- f"&workspace_id=in.({workspace_filter})"
195
- "&order=updated_at.desc"
196
- ),
197
- headers=self._headers(),
198
- )
199
- response.raise_for_status()
200
- project_rows = response.json()
201
- project_ids = [item["id"] for item in project_rows]
202
- workflow_counts: dict[str, int] = {}
203
- if project_ids:
204
- project_filter = ",".join(project_ids)
205
- workflow_response = await client.get(
206
- f"{base_url}/workflows?select=project_id&project_id=in.({project_filter})&is_archived=eq.false",
207
- headers=self._headers(),
208
- )
209
- workflow_response.raise_for_status()
210
- for workflow in workflow_response.json():
211
- project_id = workflow.get("project_id")
212
- if project_id:
213
- workflow_counts[project_id] = workflow_counts.get(project_id, 0) + 1
214
- return [
215
- ProjectSummary(
216
- **item,
217
- workflow_count=workflow_counts.get(item["id"], 0),
218
- )
219
- for item in project_rows
220
- ]
221
-
222
- async def workflow(self, workflow_id: str, user_id: str) -> WorkflowDocument:
223
- self._require_configured()
224
- async with httpx.AsyncClient(timeout=10) as client:
225
- workspace_ids = await self._workspace_ids(client, user_id)
226
- response = await client.get(
227
- f"{self.base_url}/workflows?select=workspace_id,definition&id=eq.{workflow_id}&is_archived=eq.false&limit=1",
228
- headers=self._headers(),
229
- )
230
- response.raise_for_status()
231
- rows = response.json()
232
- if not rows or rows[0]["workspace_id"] not in workspace_ids:
233
- raise ValueError("Workflow is not accessible by this user")
234
- definition = dict(rows[0]["definition"])
235
- definition["id"] = workflow_id
236
- return WorkflowDocument.model_validate(definition)
237
-
238
- async def template(self, template_id: str, user_id: str) -> WorkflowDocument:
239
- self._require_configured()
240
- async with httpx.AsyncClient(timeout=10) as client:
241
- response = await client.get(
242
- f"{self.base_url}/templates?select=name,definition,is_public,owner_id&id=eq.{template_id}&limit=1",
243
- headers=self._headers(),
244
- )
245
- response.raise_for_status()
246
- rows = response.json()
247
- if not rows or (not rows[0]["is_public"] and rows[0]["owner_id"] != user_id):
248
- raise ValueError("Template is not accessible by this user")
249
- definition = dict(rows[0]["definition"])
250
- definition.pop("id", None)
251
- definition["name"] = rows[0]["name"]
252
- return WorkflowDocument.model_validate(definition)
253
-
254
- async def templates(
255
- self,
256
- user_id: str,
257
- query: str = "",
258
- category: str | None = None,
259
- limit: int = 24,
260
- offset: int = 0,
261
- ) -> tuple[list[TemplateSummary], int]:
262
- self._require_configured()
263
- params: dict[str, str | int] = {
264
- "select": "id,name,description,category,definition,use_count,tags",
265
- "or": f"(is_public.eq.true,owner_id.eq.{user_id})",
266
- "order": "use_count.desc",
267
- "limit": limit,
268
- "offset": offset,
269
- }
270
- if query:
271
- safe_query = re.sub(r"[^\w\s-]", " ", query).strip()
272
- if safe_query:
273
- params["and"] = (
274
- f"(or(name.ilike.*{safe_query}*,description.ilike.*{safe_query}*))"
275
- )
276
- if category:
277
- safe_category = re.sub(r"[^\w\s-]", "", category).strip()
278
- if safe_category:
279
- params["category"] = f"eq.{safe_category}"
280
- async with httpx.AsyncClient(timeout=10) as client:
281
- response = await client.get(
282
- f"{self.base_url}/templates",
283
- params=params,
284
- headers={**self._headers(), "Prefer": "count=exact"},
285
- )
286
- response.raise_for_status()
287
- items = [
288
- TemplateSummary(
289
- id=row["id"],
290
- name=row["name"],
291
- description=row.get("description") or "",
292
- category=row["category"],
293
- node_count=len((row.get("definition") or {}).get("nodes", [])),
294
- use_count=row.get("use_count", 0),
295
- tags=row.get("tags") or [],
296
- )
297
- for row in response.json()
298
- ]
299
- content_range = response.headers.get("content-range", "")
300
- try:
301
- total = int(content_range.rsplit("/", 1)[1])
302
- except (IndexError, ValueError):
303
- total = len(items)
304
- return items, total
305
-
306
- async def dashboard(self, user_id: str, email: str | None) -> DashboardSummary:
307
- self._require_configured()
308
- async with httpx.AsyncClient(timeout=15) as client:
309
- workspace_ids = await self._workspace_ids(client, user_id)
310
- profile_response = await client.get(
311
- f"{self.base_url}/users?select=full_name,email,avatar_url&id=eq.{user_id}&limit=1",
312
- headers=self._headers(),
313
- )
314
- profile_response.raise_for_status()
315
- profile_rows = profile_response.json()
316
- profile = profile_rows[0] if profile_rows else {}
317
- display_name = profile.get("full_name") or (email or "Workspace member").split("@", 1)[0]
318
-
319
- if not workspace_ids:
320
- return DashboardSummary(
321
- workspace_name="No workspace",
322
- user=DashboardUser(
323
- name=display_name,
324
- email=profile.get("email") or email,
325
- avatar_url=profile.get("avatar_url"),
326
- ),
327
- stats=DashboardStats(),
328
- )
329
-
330
- workspace_filter = ",".join(workspace_ids)
331
- workspace_response = await client.get(
332
- f"{self.base_url}/workspaces?select=id,name&id=in.({workspace_filter})&order=created_at.asc",
333
- headers=self._headers(),
334
- )
335
- project_response = await client.get(
336
- f"{self.base_url}/projects?select=id,name&workspace_id=in.({workspace_filter})",
337
- headers=self._headers(),
338
- )
339
- workflow_response = await client.get(
340
- f"{self.base_url}/workflows?select=id,name,definition,is_active,updated_at,project_id&workspace_id=in.({workspace_filter})&is_archived=eq.false&order=updated_at.desc&limit=100",
341
- headers={**self._headers(), "Prefer": "count=exact"},
342
- )
343
- favorite_response = await client.get(
344
- f"{self.base_url}/favorites?select=workflow_id&user_id=eq.{user_id}&kind=eq.workflow",
345
- headers=self._headers(),
346
- )
347
- for response in (
348
- workspace_response,
349
- project_response,
350
- workflow_response,
351
- favorite_response,
352
- ):
353
- response.raise_for_status()
354
-
355
- projects = {row["id"]: row["name"] for row in project_response.json()}
356
- favorites = {row["workflow_id"] for row in favorite_response.json()}
357
- workflow_rows = workflow_response.json()
358
- workflow_ids = [row["id"] for row in workflow_rows]
359
- workflow_total = self._response_count(workflow_response)
360
- active_workflows = await self._count(
361
- client,
362
- f"workflows?select=id&workspace_id=in.({workspace_filter})&is_archived=eq.false&is_active=eq.true&limit=1",
363
- )
364
- executions = successes = 0
365
- if workflow_ids:
366
- workflow_filter = ",".join(workflow_ids)
367
- executions = await self._count(
368
- client,
369
- f"workflow_runs?select=id&workflow_id=in.({workflow_filter})&limit=1",
370
- )
371
- successes = await self._count(
372
- client,
373
- f"workflow_runs?select=id&workflow_id=in.({workflow_filter})&status=eq.success&limit=1",
374
- )
375
- ai_generations = await self._count(
376
- client,
377
- f"ai_history?select=id&user_id=eq.{user_id}&role=eq.assistant&limit=1",
378
- )
379
- template_items, _ = await self.templates(user_id, limit=6)
380
-
381
- workspaces = workspace_response.json()
382
- workspace_name = workspaces[0]["name"] if len(workspaces) == 1 else "All workspaces"
383
- return DashboardSummary(
384
- workspace_name=workspace_name,
385
- user=DashboardUser(
386
- name=display_name,
387
- email=profile.get("email") or email,
388
- avatar_url=profile.get("avatar_url"),
389
- ),
390
- stats=DashboardStats(
391
- workflows=workflow_total,
392
- projects=len(projects),
393
- active_workflows=active_workflows,
394
- executions=executions,
395
- success_rate=round(successes / executions * 100, 1) if executions else None,
396
- ai_generations=ai_generations,
397
- ),
398
- workflows=[
399
- DashboardWorkflow(
400
- id=row["id"],
401
- name=row["name"],
402
- project_name=projects.get(row.get("project_id")),
403
- updated_at=row["updated_at"],
404
- node_count=len((row.get("definition") or {}).get("nodes", [])),
405
- is_active=bool(row["is_active"]),
406
- favorite=row["id"] in favorites,
407
- )
408
- for row in workflow_rows
409
- ],
410
- templates=template_items,
411
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/validation.py DELETED
@@ -1,215 +0,0 @@
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 not credential.id:
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)