Ava2lon commited on
Commit
1319f7a
·
verified ·
1 Parent(s): b47633b

Upload 12 files

Browse files
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
- CredentialPlaceholder,
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: CredentialPlaceholder(
56
- id=str(value.get("id", "__CREDENTIAL_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: {"id": value.id, "name": value.name}
 
 
 
144
  for key, value in (node.data.credentials or {}).items()
145
  },
146
  **({"disabled": True} if node.data.disabled else {}),
 
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, TemplateSummary
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-4.1-mini"},
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
- if not self.configured:
111
- if request.workflow is None:
112
- raise ValueError("A workflow snapshot is required in local mode")
113
- _demo_shares[token_hash] = SharedWorkflowResponse(
114
- workflow=request.workflow,
115
- permission=request.permission,
116
- expires_at=expires_at,
117
  )
118
- else:
119
- async with httpx.AsyncClient(timeout=10) as client:
120
- await self._authorized_workflow(
121
- client, request.workflow_id, user_id, edit=True
122
- )
123
- response = await client.post(
124
- f"{self.base_url}/workflow_shares",
125
- headers=self._headers(),
126
- json={
127
- "id": share_id,
128
- "workflow_id": request.workflow_id,
129
- "created_by": user_id,
130
- "token_hash": token_hash,
131
- "permission": request.permission,
132
- "expires_at": expires_at.isoformat() if expires_at else None,
133
- },
134
- )
135
- response.raise_for_status()
136
  return ShareResponse(
137
  id=share_id,
138
  url=f"{self.settings.allowed_origins[0].rstrip('/')}/share/{token}",
@@ -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
- if not self.configured:
146
- shared = _demo_shares.get(token_hash)
147
- if not shared:
 
 
 
 
 
 
 
 
 
148
  raise ValueError("Share link was not found")
149
- else:
150
- async with httpx.AsyncClient(timeout=10) as client:
151
- response = await client.get(
152
- (
153
- f"{self.base_url}/workflow_shares"
154
- "?select=permission,expires_at,revoked_at,workflow:workflows(definition)"
155
- f"&token_hash=eq.{token_hash}&limit=1"
156
- ),
157
- headers=self._headers(),
158
- )
159
- response.raise_for_status()
160
- rows = response.json()
161
- if not rows or rows[0]["revoked_at"]:
162
- raise ValueError("Share link was not found")
163
- row = rows[0]
164
- shared = SharedWorkflowResponse(
165
- workflow=WorkflowDocument.model_validate(row["workflow"]["definition"]),
166
- permission=row["permission"],
167
- expires_at=row["expires_at"],
168
- )
169
  if shared.expires_at and shared.expires_at < datetime.now(UTC):
170
  raise ValueError("Share link has expired")
171
  return shared
172
 
173
  async def versions(self, workflow_id: str, user_id: str) -> list[VersionSummary]:
174
- if not self.configured:
175
- return []
176
  async with httpx.AsyncClient(timeout=10) as client:
177
  await self._authorized_workflow(client, workflow_id, user_id, edit=False)
178
  response = await client.get(
@@ -196,8 +187,7 @@ class CollaborationRepository:
196
  ]
197
 
198
  async def restore(self, workflow_id: str, version: int, user_id: str) -> WorkflowDocument:
199
- if not self.configured:
200
- raise ValueError("Version restore requires Supabase")
201
  async with httpx.AsyncClient(timeout=10) as client:
202
  await self._authorized_workflow(client, workflow_id, user_id, edit=True)
203
  response = await client.get(
@@ -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
- if not self.configured:
237
- return []
238
  async with httpx.AsyncClient(timeout=10) as client:
239
  await self._authorized_workflow(client, workflow_id, user_id, edit=False)
240
  response = await client.get(
@@ -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
- if not self.configured:
251
- return WorkflowComment(
252
- id=str(uuid4()), workflow_id=workflow_id, user_id=user_id,
253
- node_id=node_id, body=body, created_at=datetime.now(UTC).isoformat()
254
- )
255
  async with httpx.AsyncClient(timeout=10) as client:
256
  await self._authorized_workflow(client, workflow_id, user_id, edit=False)
257
  response = await client.post(
 
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
- CredentialPlaceholder,
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, CredentialPlaceholder] | None:
33
  if not types:
34
  return None
35
  return {
36
- credential_type: CredentialPlaceholder(
37
  name=f"Connect {label}", type=credential_type
38
  )
39
  for credential_type in types
@@ -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": "https://api.example.com/resource",
120
  "options": {"timeout": 30000},
121
  },
122
  [],
@@ -241,7 +268,7 @@ class RuleBasedWorkflowProvider:
241
  },
242
  meta=WorkflowMeta(
243
  description=prompt[:500],
244
- generatedBy="FlowForge rule-based provider",
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
- "Replace credential placeholders before activating the workflow.",
254
  "Review generated expressions with representative execution data.",
255
  ],
256
  )
257
 
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  class ProviderRegistry:
260
  def __init__(self) -> None:
261
  self._providers: dict[str, WorkflowProvider] = {}
@@ -264,8 +398,14 @@ class ProviderRegistry:
264
  self._providers[name] = provider
265
 
266
  def get(self, name: str) -> WorkflowProvider:
267
- return self._providers.get(name, self._providers["mock"])
 
 
 
268
 
269
 
270
  providers = ProviderRegistry()
271
- providers.register("mock", RuleBasedWorkflowProvider())
 
 
 
 
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.models.catalog import ProjectSummary
8
- from app.models.workflow import SaveRequest, SaveResponse
 
 
 
 
 
 
 
 
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
- if not self.configured:
31
- return SaveResponse(id=workflow_id, version=1, saved_at=now)
32
-
33
- base_url = f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
34
  async with httpx.AsyncClient(timeout=10) as client:
35
  membership_response = await client.get(
36
  (
@@ -128,17 +173,8 @@ class WorkflowRepository:
128
  )
129
 
130
  async def projects(self, user_id: str) -> list[ProjectSummary]:
131
- if not self.configured:
132
- return [
133
- ProjectSummary(
134
- id="demo-finance",
135
- name="Finance Ops",
136
- description="Invoice and reporting automations",
137
- workflow_count=4,
138
- updated_at=datetime.now(UTC).isoformat(),
139
- )
140
- ]
141
- base_url = f"{self.settings.supabase_url.rstrip('/')}/rest/v1"
142
  async with httpx.AsyncClient(timeout=10) as client:
143
  membership_response = await client.get(
144
  f"{base_url}/workspace_members?select=workspace_id&user_id=eq.{user_id}",
@@ -161,4 +197,215 @@ class WorkflowRepository:
161
  headers=self._headers(),
162
  )
163
  response.raise_for_status()
164
- return [ProjectSummary(**item, workflow_count=0) for item in response.json()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.startswith("__"):
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,