Upload 12 files
Browse files- app/services/adapters.py +7 -4
- app/services/catalog.py +2 -41
- app/services/collaboration.py +47 -62
- app/services/generator.py +150 -10
- app/services/repository.py +265 -18
- app/services/validation.py +1 -1
app/services/adapters.py
CHANGED
|
@@ -2,7 +2,7 @@ import json
|
|
| 2 |
from typing import Any, Protocol
|
| 3 |
|
| 4 |
from app.models.workflow import (
|
| 5 |
-
|
| 6 |
Position,
|
| 7 |
WorkflowDocument,
|
| 8 |
WorkflowEdge,
|
|
@@ -52,8 +52,8 @@ class N8nJsonAdapter:
|
|
| 52 |
node_type = node.get("type", "n8n-nodes-base.noOp")
|
| 53 |
position = node.get("position", [80 + index * 300, 200])
|
| 54 |
credentials = {
|
| 55 |
-
key:
|
| 56 |
-
id=str(value.get("id"
|
| 57 |
name=str(value.get("name", f"Connect {key}")),
|
| 58 |
type=key,
|
| 59 |
)
|
|
@@ -140,7 +140,10 @@ class N8nJsonAdapter:
|
|
| 140 |
"position": [round(node.position.x), round(node.position.y)],
|
| 141 |
"parameters": node.data.parameters,
|
| 142 |
"credentials": {
|
| 143 |
-
key: {
|
|
|
|
|
|
|
|
|
|
| 144 |
for key, value in (node.data.credentials or {}).items()
|
| 145 |
},
|
| 146 |
**({"disabled": True} if node.data.disabled else {}),
|
|
|
|
| 2 |
from typing import Any, Protocol
|
| 3 |
|
| 4 |
from app.models.workflow import (
|
| 5 |
+
CredentialReference,
|
| 6 |
Position,
|
| 7 |
WorkflowDocument,
|
| 8 |
WorkflowEdge,
|
|
|
|
| 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 |
)
|
|
|
|
| 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 {}),
|
app/services/catalog.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from app.models.catalog import NodeDefinition
|
| 2 |
|
| 3 |
|
| 4 |
NODE_CATALOG = [
|
|
@@ -102,7 +102,7 @@ NODE_CATALOG = [
|
|
| 102 |
icon="Sparkles",
|
| 103 |
color="#10a37f",
|
| 104 |
typeVersion=1.8,
|
| 105 |
-
defaults={"modelId": "gpt-
|
| 106 |
credentials=["openAiApi"],
|
| 107 |
),
|
| 108 |
NodeDefinition(
|
|
@@ -129,42 +129,3 @@ NODE_CATALOG = [
|
|
| 129 |
]
|
| 130 |
|
| 131 |
SUPPORTED_NODE_TYPES = {node.type for node in NODE_CATALOG}
|
| 132 |
-
|
| 133 |
-
TEMPLATES = [
|
| 134 |
-
TemplateSummary(
|
| 135 |
-
id="invoice-intake",
|
| 136 |
-
name="AI invoice intake",
|
| 137 |
-
description="Extract invoice data from Gmail attachments and store it in Supabase.",
|
| 138 |
-
category="Finance",
|
| 139 |
-
node_count=6,
|
| 140 |
-
use_count=1842,
|
| 141 |
-
tags=["AI", "Gmail", "Supabase", "Slack"],
|
| 142 |
-
),
|
| 143 |
-
TemplateSummary(
|
| 144 |
-
id="lead-enrichment",
|
| 145 |
-
name="Lead enrichment pipeline",
|
| 146 |
-
description="Enrich new CRM leads and route qualified accounts to sales.",
|
| 147 |
-
category="CRM",
|
| 148 |
-
node_count=9,
|
| 149 |
-
use_count=1270,
|
| 150 |
-
tags=["CRM", "AI", "Sales"],
|
| 151 |
-
),
|
| 152 |
-
TemplateSummary(
|
| 153 |
-
id="support-triage",
|
| 154 |
-
name="Support ticket triage",
|
| 155 |
-
description="Classify, prioritize, and assign incoming support tickets.",
|
| 156 |
-
category="AI",
|
| 157 |
-
node_count=8,
|
| 158 |
-
use_count=966,
|
| 159 |
-
tags=["AI", "Support", "Slack"],
|
| 160 |
-
),
|
| 161 |
-
TemplateSummary(
|
| 162 |
-
id="content-repurposing",
|
| 163 |
-
name="Content repurposing",
|
| 164 |
-
description="Turn long-form content into scheduled social posts.",
|
| 165 |
-
category="Social Media",
|
| 166 |
-
node_count=11,
|
| 167 |
-
use_count=2213,
|
| 168 |
-
tags=["Marketing", "AI", "Social"],
|
| 169 |
-
),
|
| 170 |
-
]
|
|
|
|
| 1 |
+
from app.models.catalog import NodeDefinition
|
| 2 |
|
| 3 |
|
| 4 |
NODE_CATALOG = [
|
|
|
|
| 102 |
icon="Sparkles",
|
| 103 |
color="#10a37f",
|
| 104 |
typeVersion=1.8,
|
| 105 |
+
defaults={"modelId": "gpt-5.5"},
|
| 106 |
credentials=["openAiApi"],
|
| 107 |
),
|
| 108 |
NodeDefinition(
|
|
|
|
| 129 |
]
|
| 130 |
|
| 131 |
SUPPORTED_NODE_TYPES = {node.type for node in NODE_CATALOG}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/services/collaboration.py
CHANGED
|
@@ -6,6 +6,7 @@ from uuid import uuid4
|
|
| 6 |
import httpx
|
| 7 |
|
| 8 |
from app.core.config import Settings
|
|
|
|
| 9 |
from app.models.workflow import (
|
| 10 |
ShareRequest,
|
| 11 |
ShareResponse,
|
|
@@ -16,9 +17,6 @@ from app.models.workflow import (
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
| 19 |
-
_demo_shares: dict[str, SharedWorkflowResponse] = {}
|
| 20 |
-
|
| 21 |
-
|
| 22 |
class CollaborationRepository:
|
| 23 |
def __init__(self, settings: Settings):
|
| 24 |
self.settings = settings
|
|
@@ -39,6 +37,12 @@ class CollaborationRepository:
|
|
| 39 |
"Prefer": prefer,
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
async def _workspace_access(
|
| 43 |
self, client: httpx.AsyncClient, user_id: str, *, edit: bool
|
| 44 |
) -> set[str]:
|
|
@@ -99,6 +103,7 @@ class CollaborationRepository:
|
|
| 99 |
return True
|
| 100 |
|
| 101 |
async def create_share(self, request: ShareRequest, user_id: str) -> ShareResponse:
|
|
|
|
| 102 |
token = secrets.token_urlsafe(32)
|
| 103 |
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
| 104 |
expires_at = (
|
|
@@ -107,32 +112,23 @@ class CollaborationRepository:
|
|
| 107 |
else None
|
| 108 |
)
|
| 109 |
share_id = str(uuid4())
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
_demo_shares[token_hash] = SharedWorkflowResponse(
|
| 114 |
-
workflow=request.workflow,
|
| 115 |
-
permission=request.permission,
|
| 116 |
-
expires_at=expires_at,
|
| 117 |
)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
"permission": request.permission,
|
| 132 |
-
"expires_at": expires_at.isoformat() if expires_at else None,
|
| 133 |
-
},
|
| 134 |
-
)
|
| 135 |
-
response.raise_for_status()
|
| 136 |
return ShareResponse(
|
| 137 |
id=share_id,
|
| 138 |
url=f"{self.settings.allowed_origins[0].rstrip('/')}/share/{token}",
|
|
@@ -141,38 +137,33 @@ class CollaborationRepository:
|
|
| 141 |
)
|
| 142 |
|
| 143 |
async def shared_workflow(self, token: str) -> SharedWorkflowResponse:
|
|
|
|
| 144 |
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
raise ValueError("Share link was not found")
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
f"&token_hash=eq.{token_hash}&limit=1"
|
| 156 |
-
),
|
| 157 |
-
headers=self._headers(),
|
| 158 |
-
)
|
| 159 |
-
response.raise_for_status()
|
| 160 |
-
rows = response.json()
|
| 161 |
-
if not rows or rows[0]["revoked_at"]:
|
| 162 |
-
raise ValueError("Share link was not found")
|
| 163 |
-
row = rows[0]
|
| 164 |
-
shared = SharedWorkflowResponse(
|
| 165 |
-
workflow=WorkflowDocument.model_validate(row["workflow"]["definition"]),
|
| 166 |
-
permission=row["permission"],
|
| 167 |
-
expires_at=row["expires_at"],
|
| 168 |
-
)
|
| 169 |
if shared.expires_at and shared.expires_at < datetime.now(UTC):
|
| 170 |
raise ValueError("Share link has expired")
|
| 171 |
return shared
|
| 172 |
|
| 173 |
async def versions(self, workflow_id: str, user_id: str) -> list[VersionSummary]:
|
| 174 |
-
|
| 175 |
-
return []
|
| 176 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 177 |
await self._authorized_workflow(client, workflow_id, user_id, edit=False)
|
| 178 |
response = await client.get(
|
|
@@ -196,8 +187,7 @@ class CollaborationRepository:
|
|
| 196 |
]
|
| 197 |
|
| 198 |
async def restore(self, workflow_id: str, version: int, user_id: str) -> WorkflowDocument:
|
| 199 |
-
|
| 200 |
-
raise ValueError("Version restore requires Supabase")
|
| 201 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 202 |
await self._authorized_workflow(client, workflow_id, user_id, edit=True)
|
| 203 |
response = await client.get(
|
|
@@ -233,8 +223,7 @@ class CollaborationRepository:
|
|
| 233 |
return WorkflowDocument.model_validate(definition)
|
| 234 |
|
| 235 |
async def comments(self, workflow_id: str, user_id: str) -> list[WorkflowComment]:
|
| 236 |
-
|
| 237 |
-
return []
|
| 238 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 239 |
await self._authorized_workflow(client, workflow_id, user_id, edit=False)
|
| 240 |
response = await client.get(
|
|
@@ -247,11 +236,7 @@ class CollaborationRepository:
|
|
| 247 |
async def add_comment(
|
| 248 |
self, workflow_id: str, body: str, node_id: str | None, user_id: str
|
| 249 |
) -> WorkflowComment:
|
| 250 |
-
|
| 251 |
-
return WorkflowComment(
|
| 252 |
-
id=str(uuid4()), workflow_id=workflow_id, user_id=user_id,
|
| 253 |
-
node_id=node_id, body=body, created_at=datetime.now(UTC).isoformat()
|
| 254 |
-
)
|
| 255 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 256 |
await self._authorized_workflow(client, workflow_id, user_id, edit=False)
|
| 257 |
response = await client.post(
|
|
|
|
| 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,
|
|
|
|
| 17 |
)
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
class CollaborationRepository:
|
| 21 |
def __init__(self, settings: Settings):
|
| 22 |
self.settings = settings
|
|
|
|
| 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]:
|
|
|
|
| 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 = (
|
|
|
|
| 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}",
|
|
|
|
| 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(
|
|
|
|
| 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(
|
|
|
|
| 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(
|
|
|
|
| 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(
|
app/services/generator.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
|
|
| 1 |
import re
|
| 2 |
from dataclasses import dataclass
|
| 3 |
from typing import Protocol
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
from app.models.workflow import (
|
| 6 |
-
|
| 7 |
GenerateWorkflowResponse,
|
| 8 |
Position,
|
| 9 |
WorkflowDocument,
|
|
@@ -13,9 +17,32 @@ from app.models.workflow import (
|
|
| 13 |
WorkflowNodeData,
|
| 14 |
)
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
class WorkflowProvider(Protocol):
|
| 18 |
-
async def generate(self, prompt: str) -> GenerateWorkflowResponse: ...
|
| 19 |
|
| 20 |
|
| 21 |
@dataclass(slots=True)
|
|
@@ -29,11 +56,11 @@ class PlannedNode:
|
|
| 29 |
credentials: list[str]
|
| 30 |
|
| 31 |
|
| 32 |
-
def _credential_map(types: list[str], label: str) -> dict[str,
|
| 33 |
if not types:
|
| 34 |
return None
|
| 35 |
return {
|
| 36 |
-
credential_type:
|
| 37 |
name=f"Connect {label}", type=credential_type
|
| 38 |
)
|
| 39 |
for credential_type in types
|
|
@@ -43,7 +70,7 @@ def _credential_map(types: list[str], label: str) -> dict[str, CredentialPlaceho
|
|
| 43 |
class RuleBasedWorkflowProvider:
|
| 44 |
"""CPU-friendly baseline provider used as a fallback and in tests."""
|
| 45 |
|
| 46 |
-
async def generate(self, prompt: str) -> GenerateWorkflowResponse:
|
| 47 |
text = prompt.lower()
|
| 48 |
plan: list[PlannedNode] = []
|
| 49 |
|
|
@@ -116,7 +143,7 @@ class RuleBasedWorkflowProvider:
|
|
| 116 |
"Call external API",
|
| 117 |
{
|
| 118 |
"method": "GET",
|
| 119 |
-
"url": "
|
| 120 |
"options": {"timeout": 30000},
|
| 121 |
},
|
| 122 |
[],
|
|
@@ -241,7 +268,7 @@ class RuleBasedWorkflowProvider:
|
|
| 241 |
},
|
| 242 |
meta=WorkflowMeta(
|
| 243 |
description=prompt[:500],
|
| 244 |
-
generatedBy="FlowForge
|
| 245 |
version=1,
|
| 246 |
tags=["AI generated"],
|
| 247 |
),
|
|
@@ -250,12 +277,119 @@ class RuleBasedWorkflowProvider:
|
|
| 250 |
workflow=workflow,
|
| 251 |
explanation=f"Created a {len(nodes)}-node workflow with a trigger and connected actions.",
|
| 252 |
warnings=[
|
| 253 |
-
"
|
| 254 |
"Review generated expressions with representative execution data.",
|
| 255 |
],
|
| 256 |
)
|
| 257 |
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
class ProviderRegistry:
|
| 260 |
def __init__(self) -> None:
|
| 261 |
self._providers: dict[str, WorkflowProvider] = {}
|
|
@@ -264,8 +398,14 @@ class ProviderRegistry:
|
|
| 264 |
self._providers[name] = provider
|
| 265 |
|
| 266 |
def get(self, name: str) -> WorkflowProvider:
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
|
| 270 |
providers = ProviderRegistry()
|
| 271 |
-
providers.register("
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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)
|
|
|
|
| 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
|
|
|
|
| 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 |
|
|
|
|
| 143 |
"Call external API",
|
| 144 |
{
|
| 145 |
"method": "GET",
|
| 146 |
+
"url": "={{ $env.API_BASE_URL }}",
|
| 147 |
"options": {"timeout": 30000},
|
| 148 |
},
|
| 149 |
[],
|
|
|
|
| 268 |
},
|
| 269 |
meta=WorkflowMeta(
|
| 270 |
description=prompt[:500],
|
| 271 |
+
generatedBy="FlowForge deterministic provider",
|
| 272 |
version=1,
|
| 273 |
tags=["AI generated"],
|
| 274 |
),
|
|
|
|
| 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] = {}
|
|
|
|
| 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/repository.py
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
|
|
| 1 |
from datetime import UTC, datetime
|
| 2 |
from uuid import uuid4
|
| 3 |
|
| 4 |
import httpx
|
| 5 |
|
| 6 |
from app.core.config import Settings
|
| 7 |
-
from app.
|
| 8 |
-
from app.models.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class WorkflowRepository:
|
|
@@ -24,13 +33,49 @@ class WorkflowRepository:
|
|
| 24 |
"Prefer": "return=representation",
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
async def save(self, request: SaveRequest, user_id: str) -> SaveResponse:
|
|
|
|
| 28 |
now = datetime.now(UTC).isoformat()
|
| 29 |
workflow_id = str(uuid4())
|
| 30 |
-
|
| 31 |
-
return SaveResponse(id=workflow_id, version=1, saved_at=now)
|
| 32 |
-
|
| 33 |
-
base_url = f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
|
| 34 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 35 |
membership_response = await client.get(
|
| 36 |
(
|
|
@@ -128,17 +173,8 @@ class WorkflowRepository:
|
|
| 128 |
)
|
| 129 |
|
| 130 |
async def projects(self, user_id: str) -> list[ProjectSummary]:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
ProjectSummary(
|
| 134 |
-
id="demo-finance",
|
| 135 |
-
name="Finance Ops",
|
| 136 |
-
description="Invoice and reporting automations",
|
| 137 |
-
workflow_count=4,
|
| 138 |
-
updated_at=datetime.now(UTC).isoformat(),
|
| 139 |
-
)
|
| 140 |
-
]
|
| 141 |
-
base_url = f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
|
| 142 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 143 |
membership_response = await client.get(
|
| 144 |
f"{base_url}/workspace_members?select=workspace_id&user_id=eq.{user_id}",
|
|
@@ -161,4 +197,215 @@ class WorkflowRepository:
|
|
| 161 |
headers=self._headers(),
|
| 162 |
)
|
| 163 |
response.raise_for_status()
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 |
(
|
|
|
|
| 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}",
|
|
|
|
| 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
CHANGED
|
@@ -37,7 +37,7 @@ class CredentialRule:
|
|
| 37 |
issues = []
|
| 38 |
for node in workflow.nodes:
|
| 39 |
for credential in (node.data.credentials or {}).values():
|
| 40 |
-
if credential.id
|
| 41 |
issues.append(
|
| 42 |
ValidationIssue(
|
| 43 |
code=self.code,
|
|
|
|
| 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,
|