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