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