Spaces:
Sleeping
Sleeping
Delete backend
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- backend/__init__.py +0 -0
- backend/agents/__init__.py +0 -1
- backend/agents/code_agent.py +0 -95
- backend/agents/diagnosis_agent.py +0 -81
- backend/agents/planner.py +0 -91
- backend/agents/repair_agent.py +0 -101
- backend/agents/task_handlers.py +0 -101
- backend/agents/test_agent.py +0 -94
- backend/analysis/__init__.py +0 -1
- backend/analysis/static_analyzer.py +0 -110
- backend/api/__init__.py +0 -0
- backend/api/auth.py +0 -70
- backend/api/providers.py +0 -36
- backend/api/schemas.py +0 -146
- backend/api/settings.py +0 -73
- backend/api/workflows.py +0 -370
- backend/core/__init__.py +0 -0
- backend/core/config.py +0 -59
- backend/core/dependencies.py +0 -50
- backend/core/encryption.py +0 -30
- backend/core/security.py +0 -36
- backend/db/__init__.py +0 -0
- backend/db/migrations/env.py +0 -52
- backend/db/migrations/versions/001_initial_schema.py +0 -138
- backend/db/migrations/versions/__init__.py +0 -1
- backend/db/models/__init__.py +0 -150
- backend/db/session.py +0 -50
- backend/events/__init__.py +0 -1
- backend/events/publisher.py +0 -40
- backend/execution/__init__.py +0 -1
- backend/execution/docker_runner.py +0 -265
- backend/execution/result_parser.py +0 -40
- backend/execution/sandbox.py +0 -38
- backend/execution/verification.py +0 -79
- backend/llm/__init__.py +0 -0
- backend/llm/base.py +0 -48
- backend/llm/providers/__init__.py +0 -20
- backend/llm/providers/gemini_provider.py +0 -98
- backend/llm/providers/mock_provider.py +0 -56
- backend/llm/providers/openai_provider.py +0 -99
- backend/llm/types.py +0 -21
- backend/main.py +0 -98
- backend/memory/__init__.py +0 -0
- backend/memory/attempt_repository.py +0 -63
- backend/memory/task_repository.py +0 -99
- backend/memory/workflow_repository.py +0 -84
- backend/orchestration/__init__.py +0 -1
- backend/orchestration/dag_engine.py +0 -174
- backend/orchestration/dag_schema.py +0 -42
- backend/orchestration/dag_validator.py +0 -85
backend/__init__.py
DELETED
|
File without changes
|
backend/agents/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Agent modules — planner and task handlers."""
|
|
|
|
|
|
backend/agents/code_agent.py
DELETED
|
@@ -1,95 +0,0 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
-
|
| 3 |
-
from pydantic import BaseModel, Field
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class GeneratedFile(BaseModel):
|
| 7 |
-
path: str
|
| 8 |
-
content: str
|
| 9 |
-
language: str = "python"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class CodeGenerationResult(BaseModel):
|
| 13 |
-
files: dict[str, dict[str, str]]
|
| 14 |
-
status: str = "generated"
|
| 15 |
-
|
| 16 |
-
@classmethod
|
| 17 |
-
def from_llm_payload(cls, payload: dict[str, Any]) -> "CodeGenerationResult":
|
| 18 |
-
files: dict[str, dict[str, str]] = {}
|
| 19 |
-
for item in payload.get("files", []):
|
| 20 |
-
path = item["path"].lstrip("/").replace("\\", "/")
|
| 21 |
-
files[path] = {
|
| 22 |
-
"content": item["content"],
|
| 23 |
-
"language": item.get("language", "python"),
|
| 24 |
-
}
|
| 25 |
-
if not files:
|
| 26 |
-
raise ValueError("Code generation produced no files")
|
| 27 |
-
return cls(files=files)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
CODE_GENERATION_SCHEMA: dict[str, Any] = {
|
| 31 |
-
"type": "object",
|
| 32 |
-
"properties": {
|
| 33 |
-
"files": {
|
| 34 |
-
"type": "array",
|
| 35 |
-
"items": {
|
| 36 |
-
"type": "object",
|
| 37 |
-
"properties": {
|
| 38 |
-
"path": {"type": "string"},
|
| 39 |
-
"content": {"type": "string"},
|
| 40 |
-
"language": {"type": "string"},
|
| 41 |
-
},
|
| 42 |
-
"required": ["path", "content"],
|
| 43 |
-
},
|
| 44 |
-
}
|
| 45 |
-
},
|
| 46 |
-
"required": ["files"],
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
CODE_GENERATION_SYSTEM_PROMPT = """You are a code generation agent.
|
| 50 |
-
Generate a complete, runnable Python project for the user request.
|
| 51 |
-
Return JSON with a files array. Each file must include path, content, and language.
|
| 52 |
-
|
| 53 |
-
Requirements:
|
| 54 |
-
- Use a flat or package layout that pytest can import (e.g. calculator.py or calculator/__init__.py).
|
| 55 |
-
- Include requirements.txt with only third-party packages (pytest is pre-installed in the sandbox).
|
| 56 |
-
- For stdlib-only projects, requirements.txt may be empty or contain only comments.
|
| 57 |
-
- Do not include tests — a separate agent will generate them.
|
| 58 |
-
Return only valid JSON."""
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
from backend.llm.base import LLMProvider
|
| 62 |
-
from backend.orchestration.errors import TaskExecutionError
|
| 63 |
-
from backend.orchestration.task_context import TaskContext
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
class CodeGenerationAgent:
|
| 67 |
-
def __init__(self, llm: LLMProvider) -> None:
|
| 68 |
-
self.llm = llm
|
| 69 |
-
|
| 70 |
-
async def generate(self, context: TaskContext) -> dict[str, Any]:
|
| 71 |
-
prompt = (
|
| 72 |
-
f"User request:\n{context.user_prompt}\n\n"
|
| 73 |
-
"Generate the implementation files for this project."
|
| 74 |
-
)
|
| 75 |
-
try:
|
| 76 |
-
structured = await self.llm.generate_structured(
|
| 77 |
-
prompt,
|
| 78 |
-
schema=CODE_GENERATION_SCHEMA,
|
| 79 |
-
system_prompt=CODE_GENERATION_SYSTEM_PROMPT,
|
| 80 |
-
temperature=0.2,
|
| 81 |
-
)
|
| 82 |
-
result = CodeGenerationResult.from_llm_payload(structured)
|
| 83 |
-
return result.model_dump()
|
| 84 |
-
except ValueError as exc:
|
| 85 |
-
raise TaskExecutionError(str(exc), error_type="CODE_GENERATION_ERROR") from exc
|
| 86 |
-
except Exception as exc:
|
| 87 |
-
raise TaskExecutionError(
|
| 88 |
-
f"Code generation failed: {exc}",
|
| 89 |
-
error_type="CODE_GENERATION_ERROR",
|
| 90 |
-
) from exc
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
async def run_code_generation(context: TaskContext) -> dict[str, Any]:
|
| 94 |
-
agent = CodeGenerationAgent(context.llm)
|
| 95 |
-
return await agent.generate(context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/agents/diagnosis_agent.py
DELETED
|
@@ -1,81 +0,0 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
-
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
|
| 5 |
-
from backend.llm.base import LLMProvider
|
| 6 |
-
from backend.orchestration.failure_classifier import ClassifiedFailure
|
| 7 |
-
from backend.orchestration.task_context import TaskContext
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class DiagnosisResult(BaseModel):
|
| 11 |
-
error_type: str
|
| 12 |
-
root_cause: str
|
| 13 |
-
recommended_fix: str
|
| 14 |
-
affected_files: list[str] = []
|
| 15 |
-
|
| 16 |
-
def to_dict(self) -> dict[str, Any]:
|
| 17 |
-
return self.model_dump()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
DIAGNOSIS_SCHEMA: dict[str, Any] = {
|
| 21 |
-
"type": "object",
|
| 22 |
-
"properties": {
|
| 23 |
-
"error_type": {"type": "string"},
|
| 24 |
-
"root_cause": {"type": "string"},
|
| 25 |
-
"recommended_fix": {"type": "string"},
|
| 26 |
-
"affected_files": {"type": "array", "items": {"type": "string"}},
|
| 27 |
-
},
|
| 28 |
-
"required": ["error_type", "root_cause", "recommended_fix", "affected_files"],
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
DIAGNOSIS_SYSTEM_PROMPT = """You are a failure diagnosis agent.
|
| 32 |
-
Analyze the workflow failure context and return structured JSON with root cause and fix recommendation.
|
| 33 |
-
Return only valid JSON."""
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class DiagnosisAgent:
|
| 37 |
-
def __init__(self, llm: LLMProvider) -> None:
|
| 38 |
-
self.llm = llm
|
| 39 |
-
|
| 40 |
-
async def diagnose(
|
| 41 |
-
self,
|
| 42 |
-
*,
|
| 43 |
-
user_prompt: str,
|
| 44 |
-
failure: ClassifiedFailure,
|
| 45 |
-
code_files: dict[str, dict[str, str]],
|
| 46 |
-
test_files: dict[str, dict[str, str]],
|
| 47 |
-
stdout: str = "",
|
| 48 |
-
stderr: str = "",
|
| 49 |
-
previous_attempts: list[dict[str, Any]] | None = None,
|
| 50 |
-
) -> DiagnosisResult:
|
| 51 |
-
code_listing = "\n".join(
|
| 52 |
-
f"--- {path} ---\n{meta['content']}" for path, meta in code_files.items()
|
| 53 |
-
)
|
| 54 |
-
test_listing = "\n".join(
|
| 55 |
-
f"--- {path} ---\n{meta['content']}" for path, meta in test_files.items()
|
| 56 |
-
)
|
| 57 |
-
prompt = (
|
| 58 |
-
f"User request:\n{user_prompt}\n\n"
|
| 59 |
-
f"Failure category: {failure.category.value}\n"
|
| 60 |
-
f"Failure message: {failure.message}\n"
|
| 61 |
-
f"stdout:\n{stdout}\n\nstderr:\n{stderr}\n\n"
|
| 62 |
-
f"Code files:\n{code_listing}\n\nTest files:\n{test_listing}\n\n"
|
| 63 |
-
f"Previous attempts: {len(previous_attempts or [])}\n"
|
| 64 |
-
"Diagnose the failure."
|
| 65 |
-
)
|
| 66 |
-
structured = await self.llm.generate_structured(
|
| 67 |
-
prompt,
|
| 68 |
-
schema=DIAGNOSIS_SCHEMA,
|
| 69 |
-
system_prompt=DIAGNOSIS_SYSTEM_PROMPT,
|
| 70 |
-
temperature=0.0,
|
| 71 |
-
)
|
| 72 |
-
return DiagnosisResult.model_validate(structured)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
async def stub_diagnose(failure: ClassifiedFailure) -> DiagnosisResult:
|
| 76 |
-
return DiagnosisResult(
|
| 77 |
-
error_type=failure.category.value,
|
| 78 |
-
root_cause=f"Detected {failure.category.value}: {failure.message}",
|
| 79 |
-
recommended_fix="Apply automated repair to resolve the reported failure.",
|
| 80 |
-
affected_files=[],
|
| 81 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/agents/planner.py
DELETED
|
@@ -1,91 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
from typing import Any
|
| 3 |
-
|
| 4 |
-
from backend.llm.base import LLMProvider
|
| 5 |
-
from backend.orchestration.dag_schema import DAGPlan, DAGTaskDefinition, TaskType
|
| 6 |
-
from backend.orchestration.errors import PlannerError
|
| 7 |
-
|
| 8 |
-
PLANNER_SYSTEM_PROMPT = """You are a software workflow planner.
|
| 9 |
-
Given a user coding request, produce a dependency-aware execution DAG as JSON.
|
| 10 |
-
|
| 11 |
-
Rules:
|
| 12 |
-
- Return ONLY valid JSON matching the schema.
|
| 13 |
-
- Use unique task ids (short labels like A, B, C or descriptive ids).
|
| 14 |
-
- Task types must be one of: code_generation, test_generation, static_analysis, execute.
|
| 15 |
-
- Typical flow: code_generation first, then test_generation and static_analysis in parallel, then execute depending on both.
|
| 16 |
-
- depends_on must list task ids that must complete before this task runs.
|
| 17 |
-
- Do not create cycles.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
PLANNER_OUTPUT_SCHEMA: dict[str, Any] = {
|
| 21 |
-
"type": "object",
|
| 22 |
-
"properties": {
|
| 23 |
-
"tasks": {
|
| 24 |
-
"type": "array",
|
| 25 |
-
"items": {
|
| 26 |
-
"type": "object",
|
| 27 |
-
"properties": {
|
| 28 |
-
"id": {"type": "string"},
|
| 29 |
-
"type": {
|
| 30 |
-
"type": "string",
|
| 31 |
-
"enum": [t.value for t in TaskType],
|
| 32 |
-
},
|
| 33 |
-
"depends_on": {
|
| 34 |
-
"type": "array",
|
| 35 |
-
"items": {"type": "string"},
|
| 36 |
-
},
|
| 37 |
-
},
|
| 38 |
-
"required": ["id", "type", "depends_on"],
|
| 39 |
-
},
|
| 40 |
-
}
|
| 41 |
-
},
|
| 42 |
-
"required": ["tasks"],
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class PlannerAgent:
|
| 47 |
-
"""Generates a structured DAG plan from a user prompt."""
|
| 48 |
-
|
| 49 |
-
def __init__(self, llm: LLMProvider) -> None:
|
| 50 |
-
self.llm = llm
|
| 51 |
-
|
| 52 |
-
async def create_plan(self, user_prompt: str, *, context: str | None = None) -> DAGPlan:
|
| 53 |
-
prompt = user_prompt
|
| 54 |
-
if context:
|
| 55 |
-
prompt = f"Relevant context:\n{context}\n\nUser request:\n{user_prompt}"
|
| 56 |
-
|
| 57 |
-
try:
|
| 58 |
-
structured = await self.llm.generate_structured(
|
| 59 |
-
prompt,
|
| 60 |
-
schema=PLANNER_OUTPUT_SCHEMA,
|
| 61 |
-
system_prompt=PLANNER_SYSTEM_PROMPT,
|
| 62 |
-
temperature=0.0,
|
| 63 |
-
)
|
| 64 |
-
return self.parse_plan(structured)
|
| 65 |
-
except PlannerError:
|
| 66 |
-
raise
|
| 67 |
-
except Exception as exc:
|
| 68 |
-
raise PlannerError(f"Planner LLM call failed: {exc}") from exc
|
| 69 |
-
|
| 70 |
-
@staticmethod
|
| 71 |
-
def parse_plan(data: dict[str, Any]) -> DAGPlan:
|
| 72 |
-
try:
|
| 73 |
-
tasks = [
|
| 74 |
-
DAGTaskDefinition(
|
| 75 |
-
id=task["id"],
|
| 76 |
-
type=TaskType(task["type"]),
|
| 77 |
-
depends_on=task.get("depends_on", []),
|
| 78 |
-
)
|
| 79 |
-
for task in data["tasks"]
|
| 80 |
-
]
|
| 81 |
-
return DAGPlan(tasks=tasks)
|
| 82 |
-
except (KeyError, TypeError, ValueError) as exc:
|
| 83 |
-
raise PlannerError(f"Invalid planner output structure: {exc}") from exc
|
| 84 |
-
|
| 85 |
-
@staticmethod
|
| 86 |
-
def parse_plan_json(raw_json: str) -> DAGPlan:
|
| 87 |
-
try:
|
| 88 |
-
data = json.loads(raw_json)
|
| 89 |
-
except json.JSONDecodeError as exc:
|
| 90 |
-
raise PlannerError(f"Planner output is not valid JSON: {exc}") from exc
|
| 91 |
-
return PlannerAgent.parse_plan(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/agents/repair_agent.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
-
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
|
| 5 |
-
from backend.agents.code_agent import CodeGenerationResult
|
| 6 |
-
from backend.llm.base import LLMProvider
|
| 7 |
-
from backend.agents.diagnosis_agent import DiagnosisResult
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class RepairResult(BaseModel):
|
| 11 |
-
files: dict[str, dict[str, str]]
|
| 12 |
-
repair_summary: str
|
| 13 |
-
status: str = "repaired"
|
| 14 |
-
|
| 15 |
-
def to_dict(self) -> dict[str, Any]:
|
| 16 |
-
return {
|
| 17 |
-
"files": self.files,
|
| 18 |
-
"repair_summary": self.repair_summary,
|
| 19 |
-
"status": self.status,
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
REPAIR_SCHEMA: dict[str, Any] = {
|
| 24 |
-
"type": "object",
|
| 25 |
-
"properties": {
|
| 26 |
-
"files": {
|
| 27 |
-
"type": "array",
|
| 28 |
-
"items": {
|
| 29 |
-
"type": "object",
|
| 30 |
-
"properties": {
|
| 31 |
-
"path": {"type": "string"},
|
| 32 |
-
"content": {"type": "string"},
|
| 33 |
-
"language": {"type": "string"},
|
| 34 |
-
},
|
| 35 |
-
"required": ["path", "content"],
|
| 36 |
-
},
|
| 37 |
-
},
|
| 38 |
-
"repair_summary": {"type": "string"},
|
| 39 |
-
},
|
| 40 |
-
"required": ["files", "repair_summary"],
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
REPAIR_SYSTEM_PROMPT = """You are a code repair agent.
|
| 44 |
-
Given the diagnosis and current project files, produce repaired files as JSON.
|
| 45 |
-
Return only valid JSON with files array and repair_summary."""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
class RepairAgent:
|
| 49 |
-
def __init__(self, llm: LLMProvider) -> None:
|
| 50 |
-
self.llm = llm
|
| 51 |
-
|
| 52 |
-
async def repair(
|
| 53 |
-
self,
|
| 54 |
-
*,
|
| 55 |
-
user_prompt: str,
|
| 56 |
-
diagnosis: DiagnosisResult,
|
| 57 |
-
code_files: dict[str, dict[str, str]],
|
| 58 |
-
test_files: dict[str, dict[str, str]],
|
| 59 |
-
) -> RepairResult:
|
| 60 |
-
all_files = {**code_files, **test_files}
|
| 61 |
-
listing = "\n".join(
|
| 62 |
-
f"--- {path} ---\n{meta['content']}" for path, meta in all_files.items()
|
| 63 |
-
)
|
| 64 |
-
prompt = (
|
| 65 |
-
f"User request:\n{user_prompt}\n\n"
|
| 66 |
-
f"Diagnosis:\n{diagnosis.model_dump_json()}\n\n"
|
| 67 |
-
f"Current files:\n{listing}\n\n"
|
| 68 |
-
"Produce repaired project files."
|
| 69 |
-
)
|
| 70 |
-
structured = await self.llm.generate_structured(
|
| 71 |
-
prompt,
|
| 72 |
-
schema=REPAIR_SCHEMA,
|
| 73 |
-
system_prompt=REPAIR_SYSTEM_PROMPT,
|
| 74 |
-
temperature=0.1,
|
| 75 |
-
)
|
| 76 |
-
generated = CodeGenerationResult.from_llm_payload(structured)
|
| 77 |
-
return RepairResult(
|
| 78 |
-
files=generated.files,
|
| 79 |
-
repair_summary=structured.get("repair_summary", "Repaired project files"),
|
| 80 |
-
)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def stub_repair(
|
| 84 |
-
code_files: dict[str, dict[str, str]],
|
| 85 |
-
test_files: dict[str, dict[str, str]],
|
| 86 |
-
*,
|
| 87 |
-
fix_code: bool = True,
|
| 88 |
-
) -> RepairResult:
|
| 89 |
-
"""Stub repair that fixes a common broken add() implementation for tests."""
|
| 90 |
-
files = {**code_files, **test_files}
|
| 91 |
-
if fix_code and "main.py" in files:
|
| 92 |
-
content = files["main.py"]["content"]
|
| 93 |
-
if "return a - b" in content or "return a+b-1" in content:
|
| 94 |
-
files["main.py"] = {
|
| 95 |
-
**files["main.py"],
|
| 96 |
-
"content": "def add(a, b):\n return a + b\n",
|
| 97 |
-
}
|
| 98 |
-
return RepairResult(
|
| 99 |
-
files=files,
|
| 100 |
-
repair_summary="Stub repair applied",
|
| 101 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/agents/task_handlers.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
from collections.abc import Awaitable, Callable
|
| 3 |
-
from typing import Any
|
| 4 |
-
|
| 5 |
-
from backend.agents.code_agent import run_code_generation
|
| 6 |
-
from backend.agents.test_agent import run_test_generation
|
| 7 |
-
from backend.analysis.static_analyzer import StaticAnalyzer
|
| 8 |
-
from backend.execution.verification import run_verification
|
| 9 |
-
from backend.orchestration.dag_schema import TaskType
|
| 10 |
-
from backend.orchestration.errors import TaskExecutionError
|
| 11 |
-
from backend.orchestration.task_context import TaskContext
|
| 12 |
-
|
| 13 |
-
TaskHandler = Callable[[TaskContext], Awaitable[dict[str, Any]]]
|
| 14 |
-
|
| 15 |
-
STUB_CODE_FILES = {
|
| 16 |
-
"main.py": {
|
| 17 |
-
"content": "def add(a, b):\n return a - b\n",
|
| 18 |
-
"language": "python",
|
| 19 |
-
},
|
| 20 |
-
"requirements.txt": {"content": "pytest\n", "language": "text"},
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
STUB_TEST_FILES = {
|
| 24 |
-
"tests/test_main.py": {
|
| 25 |
-
"content": "from main import add\n\ndef test_add():\n assert add(1, 2) == 3\n",
|
| 26 |
-
"language": "python",
|
| 27 |
-
}
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
async def _handle_code_generation(context: TaskContext) -> dict[str, Any]:
|
| 32 |
-
if context.use_stubs:
|
| 33 |
-
return await _stub_handler(context, TaskType.CODE_GENERATION)
|
| 34 |
-
return await run_code_generation(context)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
async def _handle_test_generation(context: TaskContext) -> dict[str, Any]:
|
| 38 |
-
if context.use_stubs:
|
| 39 |
-
return await _stub_handler(context, TaskType.TEST_GENERATION)
|
| 40 |
-
return await run_test_generation(context)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
async def _handle_static_analysis(context: TaskContext) -> dict[str, Any]:
|
| 44 |
-
if context.use_stubs:
|
| 45 |
-
return await _stub_handler(context, TaskType.STATIC_ANALYSIS)
|
| 46 |
-
files = context.generated_files
|
| 47 |
-
if not files:
|
| 48 |
-
raise TaskExecutionError(
|
| 49 |
-
"No generated code available for static analysis",
|
| 50 |
-
error_type="STATIC_ANALYSIS_ERROR",
|
| 51 |
-
)
|
| 52 |
-
analyzer = StaticAnalyzer()
|
| 53 |
-
result = analyzer.analyze_files(files)
|
| 54 |
-
if result.status == "FAIL":
|
| 55 |
-
raise TaskExecutionError(
|
| 56 |
-
"Static analysis failed",
|
| 57 |
-
error_type="STATIC_ANALYSIS_ERROR",
|
| 58 |
-
)
|
| 59 |
-
return result.to_dict()
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
async def _handle_execute(context: TaskContext) -> dict[str, Any]:
|
| 63 |
-
if context.use_stubs and not context.stub_execute_calls_docker:
|
| 64 |
-
return await _stub_handler(context, TaskType.EXECUTE)
|
| 65 |
-
return await run_verification(context)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
async def _stub_handler(context: TaskContext, task_type: TaskType) -> dict[str, Any]:
|
| 69 |
-
await asyncio.sleep(context.stub_delay)
|
| 70 |
-
if context.fail_task == task_type.value:
|
| 71 |
-
raise TaskExecutionError(
|
| 72 |
-
f"{task_type.value} failed",
|
| 73 |
-
error_type=f"{task_type.value.upper()}_ERROR",
|
| 74 |
-
)
|
| 75 |
-
if task_type == TaskType.CODE_GENERATION:
|
| 76 |
-
return {"status": "generated", "files": dict(STUB_CODE_FILES)}
|
| 77 |
-
if task_type == TaskType.TEST_GENERATION:
|
| 78 |
-
return {"status": "generated", "files": dict(STUB_TEST_FILES)}
|
| 79 |
-
if task_type == TaskType.STATIC_ANALYSIS:
|
| 80 |
-
return {"status": "PASS", "errors": [], "warnings": []}
|
| 81 |
-
if task_type == TaskType.EXECUTE:
|
| 82 |
-
return {"status": "PASS", "stdout": "", "stderr": "", "exit_code": 0}
|
| 83 |
-
return {"status": "planned", "files": {}, "task_type": task_type.value}
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
TASK_HANDLERS: dict[TaskType, TaskHandler] = {
|
| 87 |
-
TaskType.CODE_GENERATION: _handle_code_generation,
|
| 88 |
-
TaskType.TEST_GENERATION: _handle_test_generation,
|
| 89 |
-
TaskType.STATIC_ANALYSIS: _handle_static_analysis,
|
| 90 |
-
TaskType.EXECUTE: _handle_execute,
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def get_task_handler(task_type: TaskType) -> TaskHandler:
|
| 95 |
-
handler = TASK_HANDLERS.get(task_type)
|
| 96 |
-
if handler is None:
|
| 97 |
-
raise TaskExecutionError(
|
| 98 |
-
f"No handler registered for task type '{task_type}'",
|
| 99 |
-
error_type="TASK_EXECUTION_ERROR",
|
| 100 |
-
)
|
| 101 |
-
return handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/agents/test_agent.py
DELETED
|
@@ -1,94 +0,0 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
-
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
|
| 5 |
-
from backend.llm.base import LLMProvider
|
| 6 |
-
from backend.orchestration.errors import TaskExecutionError
|
| 7 |
-
from backend.orchestration.task_context import TaskContext
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class TestGenerationResult(BaseModel):
|
| 11 |
-
files: dict[str, dict[str, str]]
|
| 12 |
-
status: str = "generated"
|
| 13 |
-
|
| 14 |
-
@classmethod
|
| 15 |
-
def from_llm_payload(cls, payload: dict[str, Any]) -> "TestGenerationResult":
|
| 16 |
-
files: dict[str, dict[str, str]] = {}
|
| 17 |
-
for item in payload.get("files", []):
|
| 18 |
-
path = item["path"].lstrip("/").replace("\\", "/")
|
| 19 |
-
files[path] = {
|
| 20 |
-
"content": item["content"],
|
| 21 |
-
"language": item.get("language", "python"),
|
| 22 |
-
}
|
| 23 |
-
if not files:
|
| 24 |
-
raise ValueError("Test generation produced no files")
|
| 25 |
-
return cls(files=files)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
TEST_GENERATION_SCHEMA: dict[str, Any] = {
|
| 29 |
-
"type": "object",
|
| 30 |
-
"properties": {
|
| 31 |
-
"files": {
|
| 32 |
-
"type": "array",
|
| 33 |
-
"items": {
|
| 34 |
-
"type": "object",
|
| 35 |
-
"properties": {
|
| 36 |
-
"path": {"type": "string"},
|
| 37 |
-
"content": {"type": "string"},
|
| 38 |
-
"language": {"type": "string"},
|
| 39 |
-
},
|
| 40 |
-
"required": ["path", "content"],
|
| 41 |
-
},
|
| 42 |
-
}
|
| 43 |
-
},
|
| 44 |
-
"required": ["files"],
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
TEST_GENERATION_SYSTEM_PROMPT = """You are a test generation agent.
|
| 48 |
-
Given generated source code, produce pytest test files.
|
| 49 |
-
Return JSON with a files array (paths under tests/).
|
| 50 |
-
Do not modify implementation files.
|
| 51 |
-
Return only valid JSON."""
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
class TestGenerationAgent:
|
| 55 |
-
def __init__(self, llm: LLMProvider) -> None:
|
| 56 |
-
self.llm = llm
|
| 57 |
-
|
| 58 |
-
async def generate(self, context: TaskContext) -> dict[str, Any]:
|
| 59 |
-
code_files = context.generated_files
|
| 60 |
-
if not code_files:
|
| 61 |
-
raise TaskExecutionError(
|
| 62 |
-
"No generated code available for test generation",
|
| 63 |
-
error_type="TEST_GENERATION_ERROR",
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
file_listing = "\n\n".join(
|
| 67 |
-
f"--- {path} ---\n{meta['content']}" for path, meta in code_files.items()
|
| 68 |
-
)
|
| 69 |
-
prompt = (
|
| 70 |
-
f"User request:\n{context.user_prompt}\n\n"
|
| 71 |
-
f"Generated source files:\n{file_listing}\n\n"
|
| 72 |
-
"Generate pytest tests for the implementation."
|
| 73 |
-
)
|
| 74 |
-
try:
|
| 75 |
-
structured = await self.llm.generate_structured(
|
| 76 |
-
prompt,
|
| 77 |
-
schema=TEST_GENERATION_SCHEMA,
|
| 78 |
-
system_prompt=TEST_GENERATION_SYSTEM_PROMPT,
|
| 79 |
-
temperature=0.2,
|
| 80 |
-
)
|
| 81 |
-
result = TestGenerationResult.from_llm_payload(structured)
|
| 82 |
-
return result.model_dump()
|
| 83 |
-
except ValueError as exc:
|
| 84 |
-
raise TaskExecutionError(str(exc), error_type="TEST_GENERATION_ERROR") from exc
|
| 85 |
-
except Exception as exc:
|
| 86 |
-
raise TaskExecutionError(
|
| 87 |
-
f"Test generation failed: {exc}",
|
| 88 |
-
error_type="TEST_GENERATION_ERROR",
|
| 89 |
-
) from exc
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
async def run_test_generation(context: TaskContext) -> dict[str, Any]:
|
| 93 |
-
agent = TestGenerationAgent(context.llm)
|
| 94 |
-
return await agent.generate(context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/analysis/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Static analysis — implemented in Phase 2+."""
|
|
|
|
|
|
backend/analysis/static_analyzer.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 1 |
-
import ast
|
| 2 |
-
import re
|
| 3 |
-
from dataclasses import dataclass, field
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
from typing import Any
|
| 6 |
-
|
| 7 |
-
from backend.orchestration.errors import TaskExecutionError
|
| 8 |
-
from backend.orchestration.task_context import TaskContext
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
@dataclass
|
| 12 |
-
class AnalysisIssue:
|
| 13 |
-
file: str
|
| 14 |
-
line: int
|
| 15 |
-
message: str
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
@dataclass
|
| 19 |
-
class StaticAnalysisResult:
|
| 20 |
-
status: str
|
| 21 |
-
errors: list[dict[str, Any]] = field(default_factory=list)
|
| 22 |
-
warnings: list[dict[str, Any]] = field(default_factory=list)
|
| 23 |
-
|
| 24 |
-
def to_dict(self) -> dict[str, Any]:
|
| 25 |
-
return {
|
| 26 |
-
"status": self.status,
|
| 27 |
-
"errors": self.errors,
|
| 28 |
-
"warnings": self.warnings,
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
class StaticAnalyzer:
|
| 33 |
-
"""Analyze generated Python files without executing them."""
|
| 34 |
-
|
| 35 |
-
def analyze_files(self, files: dict[str, dict[str, str]]) -> StaticAnalysisResult:
|
| 36 |
-
errors: list[dict[str, Any]] = []
|
| 37 |
-
warnings: list[dict[str, Any]] = []
|
| 38 |
-
|
| 39 |
-
python_files = {
|
| 40 |
-
path: meta["content"]
|
| 41 |
-
for path, meta in files.items()
|
| 42 |
-
if path.endswith(".py")
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
if not python_files:
|
| 46 |
-
errors.append(
|
| 47 |
-
{"file": "", "line": 0, "message": "No Python source files found to analyze"}
|
| 48 |
-
)
|
| 49 |
-
return StaticAnalysisResult(status="FAIL", errors=errors, warnings=warnings)
|
| 50 |
-
|
| 51 |
-
for path, content in python_files.items():
|
| 52 |
-
syntax_issues = self._check_syntax(path, content)
|
| 53 |
-
errors.extend(syntax_issues)
|
| 54 |
-
warnings.extend(self._check_imports(path, content))
|
| 55 |
-
|
| 56 |
-
status = "FAIL" if errors else "PASS"
|
| 57 |
-
return StaticAnalysisResult(status=status, errors=errors, warnings=warnings)
|
| 58 |
-
|
| 59 |
-
def _check_syntax(self, path: str, content: str) -> list[dict[str, Any]]:
|
| 60 |
-
try:
|
| 61 |
-
ast.parse(content, filename=path)
|
| 62 |
-
return []
|
| 63 |
-
except SyntaxError as exc:
|
| 64 |
-
return [
|
| 65 |
-
{
|
| 66 |
-
"file": path,
|
| 67 |
-
"line": exc.lineno or 0,
|
| 68 |
-
"message": f"Syntax error: {exc.msg}",
|
| 69 |
-
}
|
| 70 |
-
]
|
| 71 |
-
|
| 72 |
-
def _check_imports(self, path: str, content: str) -> list[dict[str, Any]]:
|
| 73 |
-
warnings: list[dict[str, Any]] = []
|
| 74 |
-
for line_no, line in enumerate(content.splitlines(), start=1):
|
| 75 |
-
stripped = line.strip()
|
| 76 |
-
if stripped.startswith("import ") or stripped.startswith("from "):
|
| 77 |
-
if re.search(r"\b(os\.system|subprocess\.|eval\(|exec\()", stripped):
|
| 78 |
-
warnings.append(
|
| 79 |
-
{
|
| 80 |
-
"file": path,
|
| 81 |
-
"line": line_no,
|
| 82 |
-
"message": "Suspicious import or call pattern detected",
|
| 83 |
-
}
|
| 84 |
-
)
|
| 85 |
-
return warnings
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
async def run_static_analysis(context: TaskContext) -> dict[str, Any]:
|
| 89 |
-
files = context.all_project_files
|
| 90 |
-
if not files:
|
| 91 |
-
raise TaskExecutionError(
|
| 92 |
-
"No generated files available for static analysis",
|
| 93 |
-
error_type="STATIC_ANALYSIS_ERROR",
|
| 94 |
-
)
|
| 95 |
-
analyzer = StaticAnalyzer()
|
| 96 |
-
result = analyzer.analyze_files(files)
|
| 97 |
-
if result.status == "FAIL":
|
| 98 |
-
raise TaskExecutionError(
|
| 99 |
-
"Static analysis failed",
|
| 100 |
-
error_type="STATIC_ANALYSIS_ERROR",
|
| 101 |
-
)
|
| 102 |
-
return result.to_dict()
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def analyze_workspace(workspace: Path) -> StaticAnalysisResult:
|
| 106 |
-
files: dict[str, dict[str, str]] = {}
|
| 107 |
-
for path in workspace.rglob("*.py"):
|
| 108 |
-
relative = path.relative_to(workspace).as_posix()
|
| 109 |
-
files[relative] = {"content": path.read_text(encoding="utf-8"), "language": "python"}
|
| 110 |
-
return StaticAnalyzer().analyze_files(files)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/api/__init__.py
DELETED
|
File without changes
|
backend/api/auth.py
DELETED
|
@@ -1,70 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
-
|
| 3 |
-
from backend.api.schemas import LoginRequest, SignupRequest, TokenResponse, UserResponse
|
| 4 |
-
from backend.core.dependencies import get_current_user, get_repository
|
| 5 |
-
from backend.core.security import create_access_token, hash_password, verify_password
|
| 6 |
-
from backend.db.models import User
|
| 7 |
-
from backend.memory.workflow_repository import WorkflowRepository
|
| 8 |
-
|
| 9 |
-
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
| 13 |
-
async def signup(
|
| 14 |
-
payload: SignupRequest,
|
| 15 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 16 |
-
) -> User:
|
| 17 |
-
if await repo.get_user_by_email(payload.email):
|
| 18 |
-
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
| 19 |
-
if await repo.get_user_by_username(payload.username):
|
| 20 |
-
raise HTTPException(
|
| 21 |
-
status_code=status.HTTP_409_CONFLICT, detail="Username already taken"
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
user = await repo.create_user(
|
| 25 |
-
email=payload.email,
|
| 26 |
-
username=payload.username,
|
| 27 |
-
password_hash=hash_password(payload.password),
|
| 28 |
-
)
|
| 29 |
-
return user
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@router.post("/login", response_model=TokenResponse)
|
| 33 |
-
async def login(
|
| 34 |
-
payload: LoginRequest,
|
| 35 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 36 |
-
) -> TokenResponse:
|
| 37 |
-
user = await repo.get_user_by_email(payload.email)
|
| 38 |
-
if user is None or not verify_password(payload.password, user.password_hash):
|
| 39 |
-
raise HTTPException(
|
| 40 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 41 |
-
detail="Invalid email or password",
|
| 42 |
-
)
|
| 43 |
-
|
| 44 |
-
token = create_access_token(str(user.id))
|
| 45 |
-
return TokenResponse(access_token=token)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@router.post("/guest", response_model=TokenResponse)
|
| 49 |
-
async def guest_login(
|
| 50 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 51 |
-
) -> TokenResponse:
|
| 52 |
-
import uuid
|
| 53 |
-
guest_id = str(uuid.uuid4())
|
| 54 |
-
guest_email = f"guest-{guest_id}@autoforge.local"
|
| 55 |
-
guest_username = f"Guest_{guest_id[:8]}"
|
| 56 |
-
|
| 57 |
-
# Auto-create the guest user with a random password
|
| 58 |
-
user = await repo.create_user(
|
| 59 |
-
email=guest_email,
|
| 60 |
-
username=guest_username,
|
| 61 |
-
password_hash=hash_password(guest_id),
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
token = create_access_token(str(user.id))
|
| 65 |
-
return TokenResponse(access_token=token)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@router.get("/me", response_model=UserResponse)
|
| 69 |
-
async def me(current_user: User = Depends(get_current_user)) -> User:
|
| 70 |
-
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/api/providers.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
|
| 3 |
-
from backend.api.schemas import ProviderInfo, ProviderListResponse
|
| 4 |
-
from backend.llm.providers import list_supported_providers
|
| 5 |
-
|
| 6 |
-
router = APIRouter(prefix="/settings", tags=["settings"])
|
| 7 |
-
|
| 8 |
-
OPENAI_MODELS = [
|
| 9 |
-
"gpt-4o",
|
| 10 |
-
"gpt-4o-mini",
|
| 11 |
-
"gpt-4.1",
|
| 12 |
-
"gpt-4.1-mini",
|
| 13 |
-
]
|
| 14 |
-
|
| 15 |
-
GEMINI_MODELS = [
|
| 16 |
-
"gemini-3.1-pro",
|
| 17 |
-
"gemini-3.1-flash",
|
| 18 |
-
]
|
| 19 |
-
|
| 20 |
-
PROVIDER_MODELS: dict[str, list[str]] = {
|
| 21 |
-
"openai": OPENAI_MODELS,
|
| 22 |
-
"gemini": GEMINI_MODELS,
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
@router.get("/providers", response_model=ProviderListResponse)
|
| 27 |
-
async def list_providers() -> ProviderListResponse:
|
| 28 |
-
providers = [
|
| 29 |
-
ProviderInfo(
|
| 30 |
-
id=provider_id,
|
| 31 |
-
name=provider_id.replace("_", " ").title(),
|
| 32 |
-
models=PROVIDER_MODELS.get(provider_id, []),
|
| 33 |
-
)
|
| 34 |
-
for provider_id in list_supported_providers()
|
| 35 |
-
]
|
| 36 |
-
return ProviderListResponse(providers=providers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/api/schemas.py
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
from datetime import datetime
|
| 2 |
-
|
| 3 |
-
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class SignupRequest(BaseModel):
|
| 7 |
-
email: EmailStr
|
| 8 |
-
username: str = Field(min_length=3, max_length=100)
|
| 9 |
-
password: str = Field(min_length=8, max_length=128)
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class LoginRequest(BaseModel):
|
| 13 |
-
email: EmailStr
|
| 14 |
-
password: str
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class TokenResponse(BaseModel):
|
| 18 |
-
access_token: str
|
| 19 |
-
token_type: str = "bearer"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class UserResponse(BaseModel):
|
| 23 |
-
model_config = ConfigDict(from_attributes=True)
|
| 24 |
-
|
| 25 |
-
id: int
|
| 26 |
-
email: str
|
| 27 |
-
username: str
|
| 28 |
-
created_at: datetime
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class WorkflowCreateRequest(BaseModel):
|
| 32 |
-
user_prompt: str = Field(min_length=1)
|
| 33 |
-
llm_provider: str = Field(min_length=1, max_length=64)
|
| 34 |
-
llm_model: str = Field(min_length=1, max_length=128)
|
| 35 |
-
api_key: str | None = Field(default=None, min_length=1)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
class WorkflowResponse(BaseModel):
|
| 39 |
-
model_config = ConfigDict(from_attributes=True)
|
| 40 |
-
|
| 41 |
-
workflow_id: str
|
| 42 |
-
user_prompt: str
|
| 43 |
-
status: str
|
| 44 |
-
llm_provider: str | None
|
| 45 |
-
llm_model: str | None
|
| 46 |
-
planner_output: dict | None
|
| 47 |
-
created_at: datetime
|
| 48 |
-
completed_at: datetime | None
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
class WorkflowListResponse(BaseModel):
|
| 52 |
-
workflows: list[WorkflowResponse]
|
| 53 |
-
total: int
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class ApiCredentialCreateRequest(BaseModel):
|
| 57 |
-
provider: str = Field(min_length=1, max_length=64)
|
| 58 |
-
api_key: str = Field(min_length=1)
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
class ApiCredentialResponse(BaseModel):
|
| 62 |
-
model_config = ConfigDict(from_attributes=True)
|
| 63 |
-
|
| 64 |
-
id: int
|
| 65 |
-
provider: str
|
| 66 |
-
api_key_hint: str
|
| 67 |
-
created_at: datetime
|
| 68 |
-
updated_at: datetime
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
class HealthResponse(BaseModel):
|
| 72 |
-
status: str
|
| 73 |
-
database: str
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
class TaskResponse(BaseModel):
|
| 77 |
-
model_config = ConfigDict(from_attributes=True)
|
| 78 |
-
|
| 79 |
-
task_key: str
|
| 80 |
-
task_type: str
|
| 81 |
-
status: str
|
| 82 |
-
depends_on: list[str] | None
|
| 83 |
-
output: dict | None
|
| 84 |
-
started_at: datetime | None
|
| 85 |
-
completed_at: datetime | None
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class TaskListResponse(BaseModel):
|
| 89 |
-
tasks: list[TaskResponse]
|
| 90 |
-
total: int
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
class EventResponse(BaseModel):
|
| 94 |
-
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
| 95 |
-
|
| 96 |
-
id: int
|
| 97 |
-
event_type: str
|
| 98 |
-
message: str
|
| 99 |
-
task_id: int | None
|
| 100 |
-
metadata: dict | None = Field(default=None, validation_alias="event_metadata")
|
| 101 |
-
timestamp: datetime
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
class AttemptResponse(BaseModel):
|
| 105 |
-
model_config = ConfigDict(from_attributes=True)
|
| 106 |
-
|
| 107 |
-
id: int
|
| 108 |
-
task_id: int | None
|
| 109 |
-
attempt_number: int
|
| 110 |
-
generated_code: dict | None
|
| 111 |
-
generated_tests: dict | None
|
| 112 |
-
static_analysis_result: dict | None
|
| 113 |
-
stdout: str | None
|
| 114 |
-
stderr: str | None
|
| 115 |
-
exit_code: int | None
|
| 116 |
-
result: str | None
|
| 117 |
-
error: dict | None
|
| 118 |
-
created_at: datetime
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
class AttemptListResponse(BaseModel):
|
| 122 |
-
attempts: list[AttemptResponse]
|
| 123 |
-
total: int
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
class WorkflowStartRequest(BaseModel):
|
| 127 |
-
api_key: str | None = Field(default=None, min_length=1)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
class ProviderInfo(BaseModel):
|
| 131 |
-
id: str
|
| 132 |
-
name: str
|
| 133 |
-
models: list[str]
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
class ProviderListResponse(BaseModel):
|
| 137 |
-
providers: list[ProviderInfo]
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
class EventListResponse(BaseModel):
|
| 141 |
-
events: list[EventResponse]
|
| 142 |
-
total: int
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
class WorkflowStartResponse(WorkflowResponse):
|
| 146 |
-
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/api/settings.py
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
-
from sqlalchemy import select
|
| 3 |
-
|
| 4 |
-
from backend.api.schemas import ApiCredentialCreateRequest, ApiCredentialResponse
|
| 5 |
-
from backend.core.dependencies import get_current_user, get_repository
|
| 6 |
-
from backend.core.encryption import encrypt_value, redact_secret
|
| 7 |
-
from backend.db.models import ApiCredential, User
|
| 8 |
-
from backend.db.session import get_db
|
| 9 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
-
|
| 11 |
-
router = APIRouter(prefix="/settings/api-credentials", tags=["settings"])
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@router.post("", response_model=ApiCredentialResponse, status_code=status.HTTP_201_CREATED)
|
| 15 |
-
async def create_api_credential(
|
| 16 |
-
payload: ApiCredentialCreateRequest,
|
| 17 |
-
current_user: User = Depends(get_current_user),
|
| 18 |
-
db: AsyncSession = Depends(get_db),
|
| 19 |
-
) -> ApiCredentialResponse:
|
| 20 |
-
credential = ApiCredential(
|
| 21 |
-
user_id=current_user.id,
|
| 22 |
-
provider=payload.provider.lower().strip(),
|
| 23 |
-
encrypted_api_key=encrypt_value(payload.api_key),
|
| 24 |
-
)
|
| 25 |
-
db.add(credential)
|
| 26 |
-
await db.flush()
|
| 27 |
-
await db.refresh(credential)
|
| 28 |
-
return ApiCredentialResponse(
|
| 29 |
-
id=credential.id,
|
| 30 |
-
provider=credential.provider,
|
| 31 |
-
api_key_hint=redact_secret(payload.api_key),
|
| 32 |
-
created_at=credential.created_at,
|
| 33 |
-
updated_at=credential.updated_at,
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
@router.get("", response_model=list[ApiCredentialResponse])
|
| 38 |
-
async def list_api_credentials(
|
| 39 |
-
current_user: User = Depends(get_current_user),
|
| 40 |
-
db: AsyncSession = Depends(get_db),
|
| 41 |
-
) -> list[ApiCredentialResponse]:
|
| 42 |
-
result = await db.execute(
|
| 43 |
-
select(ApiCredential).where(ApiCredential.user_id == current_user.id)
|
| 44 |
-
)
|
| 45 |
-
credentials = result.scalars().all()
|
| 46 |
-
return [
|
| 47 |
-
ApiCredentialResponse(
|
| 48 |
-
id=cred.id,
|
| 49 |
-
provider=cred.provider,
|
| 50 |
-
api_key_hint="********",
|
| 51 |
-
created_at=cred.created_at,
|
| 52 |
-
updated_at=cred.updated_at,
|
| 53 |
-
)
|
| 54 |
-
for cred in credentials
|
| 55 |
-
]
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
@router.delete("/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 59 |
-
async def delete_api_credential(
|
| 60 |
-
credential_id: int,
|
| 61 |
-
current_user: User = Depends(get_current_user),
|
| 62 |
-
db: AsyncSession = Depends(get_db),
|
| 63 |
-
) -> None:
|
| 64 |
-
result = await db.execute(
|
| 65 |
-
select(ApiCredential).where(
|
| 66 |
-
ApiCredential.id == credential_id,
|
| 67 |
-
ApiCredential.user_id == current_user.id,
|
| 68 |
-
)
|
| 69 |
-
)
|
| 70 |
-
credential = result.scalar_one_or_none()
|
| 71 |
-
if credential is None:
|
| 72 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
|
| 73 |
-
await db.delete(credential)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/api/workflows.py
DELETED
|
@@ -1,370 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import json
|
| 3 |
-
import logging
|
| 4 |
-
|
| 5 |
-
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
| 6 |
-
from fastapi.responses import StreamingResponse
|
| 7 |
-
from sqlalchemy import select
|
| 8 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
-
|
| 10 |
-
from backend.api.schemas import (
|
| 11 |
-
AttemptListResponse,
|
| 12 |
-
AttemptResponse,
|
| 13 |
-
EventListResponse,
|
| 14 |
-
EventResponse,
|
| 15 |
-
TaskListResponse,
|
| 16 |
-
TaskResponse,
|
| 17 |
-
WorkflowCreateRequest,
|
| 18 |
-
WorkflowListResponse,
|
| 19 |
-
WorkflowResponse,
|
| 20 |
-
WorkflowStartRequest,
|
| 21 |
-
WorkflowStartResponse,
|
| 22 |
-
)
|
| 23 |
-
from backend.core.dependencies import get_current_user, get_repository
|
| 24 |
-
from backend.core.encryption import decrypt_value
|
| 25 |
-
from backend.core.security import decode_access_token
|
| 26 |
-
from backend.db.models import ApiCredential, Attempt, Event, User, Workflow, WorkflowStatus
|
| 27 |
-
from backend.db.session import AsyncSessionLocal, get_db
|
| 28 |
-
from backend.events.publisher import EventPublisher
|
| 29 |
-
from backend.llm.providers import get_llm_provider
|
| 30 |
-
from backend.memory.workflow_repository import WorkflowRepository
|
| 31 |
-
from backend.orchestration.workflow_runner import WorkflowRunner
|
| 32 |
-
|
| 33 |
-
logger = logging.getLogger(__name__)
|
| 34 |
-
router = APIRouter(prefix="/workflows", tags=["workflows"])
|
| 35 |
-
|
| 36 |
-
TERMINAL_WORKFLOW_STATUSES = {
|
| 37 |
-
WorkflowStatus.SUCCESS,
|
| 38 |
-
WorkflowStatus.FAILED,
|
| 39 |
-
WorkflowStatus.CANCELLED,
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _to_workflow_response(workflow) -> WorkflowResponse:
|
| 44 |
-
return WorkflowResponse(
|
| 45 |
-
workflow_id=workflow.workflow_id,
|
| 46 |
-
user_prompt=workflow.user_prompt,
|
| 47 |
-
status=workflow.status,
|
| 48 |
-
llm_provider=workflow.llm_provider,
|
| 49 |
-
llm_model=workflow.llm_model,
|
| 50 |
-
planner_output=workflow.planner_output,
|
| 51 |
-
created_at=workflow.created_at,
|
| 52 |
-
completed_at=workflow.completed_at,
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
async def _resolve_api_key(
|
| 57 |
-
db: AsyncSession,
|
| 58 |
-
user_id: int,
|
| 59 |
-
provider: str,
|
| 60 |
-
request_api_key: str | None,
|
| 61 |
-
) -> str:
|
| 62 |
-
if request_api_key:
|
| 63 |
-
return request_api_key
|
| 64 |
-
|
| 65 |
-
result = await db.execute(
|
| 66 |
-
select(ApiCredential).where(
|
| 67 |
-
ApiCredential.user_id == user_id,
|
| 68 |
-
ApiCredential.provider == provider.lower().strip(),
|
| 69 |
-
)
|
| 70 |
-
)
|
| 71 |
-
credential = result.scalar_one_or_none()
|
| 72 |
-
if credential is None:
|
| 73 |
-
raise HTTPException(
|
| 74 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 75 |
-
detail="API key required. Provide api_key or store credentials in settings.",
|
| 76 |
-
)
|
| 77 |
-
return decrypt_value(credential.encrypted_api_key)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
async def _execute_workflow_background(
|
| 81 |
-
workflow_id: str,
|
| 82 |
-
user_id: int,
|
| 83 |
-
api_key: str | None,
|
| 84 |
-
) -> None:
|
| 85 |
-
async with AsyncSessionLocal() as db:
|
| 86 |
-
try:
|
| 87 |
-
repo = WorkflowRepository(db)
|
| 88 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, user_id)
|
| 89 |
-
if workflow is None:
|
| 90 |
-
return
|
| 91 |
-
llm = get_llm_provider(
|
| 92 |
-
workflow.llm_provider,
|
| 93 |
-
api_key=api_key,
|
| 94 |
-
model=workflow.llm_model,
|
| 95 |
-
)
|
| 96 |
-
import shutil
|
| 97 |
-
|
| 98 |
-
has_docker = shutil.which("docker") is not None
|
| 99 |
-
if not has_docker:
|
| 100 |
-
raise RuntimeError("DOCKER_UNAVAILABLE: Sandbox unavailable — Docker is required to execute generated code.")
|
| 101 |
-
|
| 102 |
-
runner = WorkflowRunner(db)
|
| 103 |
-
await runner.run(workflow, llm)
|
| 104 |
-
await db.commit()
|
| 105 |
-
except Exception as exc:
|
| 106 |
-
logger.exception("Background workflow execution failed for %s", workflow_id)
|
| 107 |
-
await db.rollback()
|
| 108 |
-
try:
|
| 109 |
-
repo = WorkflowRepository(db)
|
| 110 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, user_id)
|
| 111 |
-
if workflow and workflow.status == WorkflowStatus.RUNNING:
|
| 112 |
-
runner = WorkflowRunner(db)
|
| 113 |
-
await runner._fail_workflow(
|
| 114 |
-
workflow,
|
| 115 |
-
message=str(exc),
|
| 116 |
-
error_type="WORKFLOW_EXECUTION_ERROR",
|
| 117 |
-
)
|
| 118 |
-
await db.commit()
|
| 119 |
-
except Exception:
|
| 120 |
-
logger.exception("Failed to mark workflow %s as failed", workflow_id)
|
| 121 |
-
await db.rollback()
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
@router.post("", response_model=WorkflowResponse, status_code=status.HTTP_201_CREATED)
|
| 125 |
-
async def create_workflow(
|
| 126 |
-
payload: WorkflowCreateRequest,
|
| 127 |
-
current_user: User = Depends(get_current_user),
|
| 128 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 129 |
-
db: AsyncSession = Depends(get_db),
|
| 130 |
-
) -> WorkflowResponse:
|
| 131 |
-
workflow = await repo.create_workflow(
|
| 132 |
-
user_id=current_user.id,
|
| 133 |
-
user_prompt=payload.user_prompt,
|
| 134 |
-
llm_provider=payload.llm_provider,
|
| 135 |
-
llm_model=payload.llm_model,
|
| 136 |
-
)
|
| 137 |
-
if payload.api_key:
|
| 138 |
-
existing = await db.execute(
|
| 139 |
-
select(ApiCredential).where(
|
| 140 |
-
ApiCredential.user_id == current_user.id,
|
| 141 |
-
ApiCredential.provider == payload.llm_provider.lower().strip(),
|
| 142 |
-
)
|
| 143 |
-
)
|
| 144 |
-
if existing.scalar_one_or_none() is None:
|
| 145 |
-
from backend.core.encryption import encrypt_value
|
| 146 |
-
|
| 147 |
-
db.add(
|
| 148 |
-
ApiCredential(
|
| 149 |
-
user_id=current_user.id,
|
| 150 |
-
provider=payload.llm_provider.lower().strip(),
|
| 151 |
-
encrypted_api_key=encrypt_value(payload.api_key),
|
| 152 |
-
)
|
| 153 |
-
)
|
| 154 |
-
return _to_workflow_response(workflow)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
@router.post("/{workflow_id}/start", response_model=WorkflowStartResponse, status_code=status.HTTP_202_ACCEPTED)
|
| 158 |
-
async def start_workflow(
|
| 159 |
-
workflow_id: str,
|
| 160 |
-
payload: WorkflowStartRequest,
|
| 161 |
-
background_tasks: BackgroundTasks,
|
| 162 |
-
current_user: User = Depends(get_current_user),
|
| 163 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 164 |
-
db: AsyncSession = Depends(get_db),
|
| 165 |
-
) -> WorkflowStartResponse:
|
| 166 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 167 |
-
if workflow is None:
|
| 168 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 169 |
-
if workflow.status not in {WorkflowStatus.CREATED, WorkflowStatus.FAILED}:
|
| 170 |
-
raise HTTPException(
|
| 171 |
-
status_code=status.HTTP_409_CONFLICT,
|
| 172 |
-
detail=f"Workflow cannot be started from status {workflow.status}",
|
| 173 |
-
)
|
| 174 |
-
if not workflow.llm_provider or not workflow.llm_model:
|
| 175 |
-
raise HTTPException(
|
| 176 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 177 |
-
detail="Workflow is missing LLM provider configuration",
|
| 178 |
-
)
|
| 179 |
-
|
| 180 |
-
resolved_key = await _resolve_api_key(
|
| 181 |
-
db,
|
| 182 |
-
current_user.id,
|
| 183 |
-
workflow.llm_provider,
|
| 184 |
-
payload.api_key,
|
| 185 |
-
)
|
| 186 |
-
|
| 187 |
-
workflow.status = WorkflowStatus.RUNNING
|
| 188 |
-
await db.flush()
|
| 189 |
-
|
| 190 |
-
background_tasks.add_task(
|
| 191 |
-
_execute_workflow_background,
|
| 192 |
-
workflow_id,
|
| 193 |
-
current_user.id,
|
| 194 |
-
resolved_key,
|
| 195 |
-
)
|
| 196 |
-
return _to_workflow_response(workflow)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
@router.get("", response_model=WorkflowListResponse)
|
| 200 |
-
async def list_workflows(
|
| 201 |
-
current_user: User = Depends(get_current_user),
|
| 202 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 203 |
-
) -> WorkflowListResponse:
|
| 204 |
-
workflows = await repo.list_workflows_for_user(current_user.id)
|
| 205 |
-
items = [_to_workflow_response(wf) for wf in workflows]
|
| 206 |
-
return WorkflowListResponse(workflows=items, total=len(items))
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
@router.get("/{workflow_id}", response_model=WorkflowResponse)
|
| 210 |
-
async def get_workflow(
|
| 211 |
-
workflow_id: str,
|
| 212 |
-
current_user: User = Depends(get_current_user),
|
| 213 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 214 |
-
) -> WorkflowResponse:
|
| 215 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 216 |
-
if workflow is None:
|
| 217 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 218 |
-
return _to_workflow_response(workflow)
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
@router.get("/{workflow_id}/tasks", response_model=TaskListResponse)
|
| 222 |
-
async def list_workflow_tasks(
|
| 223 |
-
workflow_id: str,
|
| 224 |
-
current_user: User = Depends(get_current_user),
|
| 225 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 226 |
-
db: AsyncSession = Depends(get_db),
|
| 227 |
-
) -> TaskListResponse:
|
| 228 |
-
from backend.memory.task_repository import TaskRepository
|
| 229 |
-
|
| 230 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 231 |
-
if workflow is None:
|
| 232 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 233 |
-
|
| 234 |
-
tasks = await TaskRepository(db).get_tasks_for_workflow(workflow.id)
|
| 235 |
-
items = [
|
| 236 |
-
TaskResponse(
|
| 237 |
-
task_key=task.task_key,
|
| 238 |
-
task_type=task.task_type,
|
| 239 |
-
status=task.status,
|
| 240 |
-
depends_on=task.depends_on,
|
| 241 |
-
output=task.output,
|
| 242 |
-
started_at=task.started_at,
|
| 243 |
-
completed_at=task.completed_at,
|
| 244 |
-
)
|
| 245 |
-
for task in tasks
|
| 246 |
-
]
|
| 247 |
-
return TaskListResponse(tasks=items, total=len(items))
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
@router.get("/{workflow_id}/events", response_model=EventListResponse)
|
| 251 |
-
async def list_workflow_events(
|
| 252 |
-
workflow_id: str,
|
| 253 |
-
after_id: int = 0,
|
| 254 |
-
current_user: User = Depends(get_current_user),
|
| 255 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 256 |
-
db: AsyncSession = Depends(get_db),
|
| 257 |
-
) -> EventListResponse:
|
| 258 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 259 |
-
if workflow is None:
|
| 260 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 261 |
-
|
| 262 |
-
query = (
|
| 263 |
-
select(Event)
|
| 264 |
-
.where(Event.workflow_id == workflow.id, Event.id > after_id)
|
| 265 |
-
.order_by(Event.timestamp.asc())
|
| 266 |
-
)
|
| 267 |
-
events = (await db.execute(query)).scalars().all()
|
| 268 |
-
items = [
|
| 269 |
-
EventResponse(
|
| 270 |
-
id=event.id,
|
| 271 |
-
event_type=event.event_type,
|
| 272 |
-
message=event.message,
|
| 273 |
-
task_id=event.task_id,
|
| 274 |
-
event_metadata=event.event_metadata,
|
| 275 |
-
timestamp=event.timestamp,
|
| 276 |
-
)
|
| 277 |
-
for event in events
|
| 278 |
-
]
|
| 279 |
-
return EventListResponse(events=items, total=len(items))
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
@router.get("/{workflow_id}/events/stream")
|
| 283 |
-
async def stream_workflow_events(
|
| 284 |
-
workflow_id: str,
|
| 285 |
-
token: str = Query(...),
|
| 286 |
-
after_id: int = Query(0),
|
| 287 |
-
db: AsyncSession = Depends(get_db),
|
| 288 |
-
):
|
| 289 |
-
user_id_str = decode_access_token(token)
|
| 290 |
-
if user_id_str is None:
|
| 291 |
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 292 |
-
user_id = int(user_id_str)
|
| 293 |
-
repo = WorkflowRepository(db)
|
| 294 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, user_id)
|
| 295 |
-
if workflow is None:
|
| 296 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 297 |
-
|
| 298 |
-
async def event_generator():
|
| 299 |
-
cursor = after_id
|
| 300 |
-
idle_ticks = 0
|
| 301 |
-
while True:
|
| 302 |
-
result = await db.execute(
|
| 303 |
-
select(Event)
|
| 304 |
-
.where(Event.workflow_id == workflow.id, Event.id > cursor)
|
| 305 |
-
.order_by(Event.id.asc())
|
| 306 |
-
)
|
| 307 |
-
new_events = result.scalars().all()
|
| 308 |
-
if new_events:
|
| 309 |
-
idle_ticks = 0
|
| 310 |
-
for event in new_events:
|
| 311 |
-
cursor = event.id
|
| 312 |
-
payload = {
|
| 313 |
-
"id": event.id,
|
| 314 |
-
"event_type": event.event_type,
|
| 315 |
-
"message": event.message,
|
| 316 |
-
"task_id": event.task_id,
|
| 317 |
-
"metadata": event.event_metadata,
|
| 318 |
-
"timestamp": event.timestamp.isoformat(),
|
| 319 |
-
}
|
| 320 |
-
yield f"data: {json.dumps(payload)}\n\n"
|
| 321 |
-
else:
|
| 322 |
-
idle_ticks += 1
|
| 323 |
-
|
| 324 |
-
wf_result = await db.execute(
|
| 325 |
-
select(Workflow.status).where(Workflow.id == workflow.id)
|
| 326 |
-
)
|
| 327 |
-
current_status = wf_result.scalar_one_or_none()
|
| 328 |
-
if current_status in TERMINAL_WORKFLOW_STATUSES and idle_ticks >= 3:
|
| 329 |
-
yield f"data: {json.dumps({'event_type': 'STREAM_END', 'message': 'Workflow finished'})}\n\n"
|
| 330 |
-
break
|
| 331 |
-
await asyncio.sleep(1)
|
| 332 |
-
|
| 333 |
-
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
@router.get("/{workflow_id}/attempts", response_model=AttemptListResponse)
|
| 337 |
-
async def list_workflow_attempts(
|
| 338 |
-
workflow_id: str,
|
| 339 |
-
current_user: User = Depends(get_current_user),
|
| 340 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 341 |
-
db: AsyncSession = Depends(get_db),
|
| 342 |
-
) -> AttemptListResponse:
|
| 343 |
-
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 344 |
-
if workflow is None:
|
| 345 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 346 |
-
|
| 347 |
-
result = await db.execute(
|
| 348 |
-
select(Attempt)
|
| 349 |
-
.where(Attempt.workflow_id == workflow.id)
|
| 350 |
-
.order_by(Attempt.attempt_number.asc(), Attempt.id.asc())
|
| 351 |
-
)
|
| 352 |
-
attempts = result.scalars().all()
|
| 353 |
-
items = [
|
| 354 |
-
AttemptResponse(
|
| 355 |
-
id=attempt.id,
|
| 356 |
-
task_id=attempt.task_id,
|
| 357 |
-
attempt_number=attempt.attempt_number,
|
| 358 |
-
generated_code=attempt.generated_code,
|
| 359 |
-
generated_tests=attempt.generated_tests,
|
| 360 |
-
static_analysis_result=attempt.static_analysis_result,
|
| 361 |
-
stdout=attempt.stdout,
|
| 362 |
-
stderr=attempt.stderr,
|
| 363 |
-
exit_code=attempt.exit_code,
|
| 364 |
-
result=attempt.result,
|
| 365 |
-
error=attempt.error,
|
| 366 |
-
created_at=attempt.created_at,
|
| 367 |
-
)
|
| 368 |
-
for attempt in attempts
|
| 369 |
-
]
|
| 370 |
-
return AttemptListResponse(attempts=items, total=len(items))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/core/__init__.py
DELETED
|
File without changes
|
backend/core/config.py
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import tempfile
|
| 3 |
-
from functools import lru_cache
|
| 4 |
-
|
| 5 |
-
from pydantic import field_validator
|
| 6 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _default_workspace_root() -> str:
|
| 10 |
-
base = os.path.join(tempfile.gettempdir(), "autoforge", "workspaces")
|
| 11 |
-
os.makedirs(base, exist_ok=True)
|
| 12 |
-
return base
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class Settings(BaseSettings):
|
| 16 |
-
model_config = SettingsConfigDict(
|
| 17 |
-
env_file=".env",
|
| 18 |
-
env_file_encoding="utf-8",
|
| 19 |
-
case_sensitive=False,
|
| 20 |
-
extra="ignore",
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
database_url: str = "sqlite+aiosqlite:////tmp/autoforge.db"
|
| 24 |
-
|
| 25 |
-
@field_validator("database_url", mode="before")
|
| 26 |
-
@classmethod
|
| 27 |
-
def normalize_database_url(cls, v: str | None) -> str:
|
| 28 |
-
if not v or not isinstance(v, str) or not v.strip():
|
| 29 |
-
return "sqlite+aiosqlite:////tmp/autoforge.db"
|
| 30 |
-
v = v.strip()
|
| 31 |
-
# If someone accidentally pasted an HTTP/HTTPS URL (e.g. dashboard URL)
|
| 32 |
-
if v.startswith("http://") or v.startswith("https://"):
|
| 33 |
-
return "sqlite+aiosqlite:////tmp/autoforge.db"
|
| 34 |
-
if v.startswith("postgres://"):
|
| 35 |
-
return v.replace("postgres://", "postgresql+asyncpg://", 1)
|
| 36 |
-
if v.startswith("postgresql://") and not v.startswith("postgresql+asyncpg://"):
|
| 37 |
-
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
| 38 |
-
if not (v.startswith("sqlite") or v.startswith("postgresql")):
|
| 39 |
-
return "sqlite+aiosqlite:////tmp/autoforge.db"
|
| 40 |
-
return v
|
| 41 |
-
secret_key: str = "change-me-in-production-use-a-long-random-string"
|
| 42 |
-
encryption_key: str = "change-me-32-byte-base64-fernet-key-here="
|
| 43 |
-
algorithm: str = "HS256"
|
| 44 |
-
access_token_expire_minutes: int = 60 * 24
|
| 45 |
-
log_level: str = "INFO"
|
| 46 |
-
docker_host: str = "unix:///var/run/docker.sock"
|
| 47 |
-
max_retries: int = 4
|
| 48 |
-
workspace_root: str = _default_workspace_root()
|
| 49 |
-
default_timeout: int = 300
|
| 50 |
-
cors_origins: str = "http://localhost:3000,http://localhost:5173"
|
| 51 |
-
docker_image: str = "autoforge-sandbox:3.12"
|
| 52 |
-
docker_memory_limit: str = "512m"
|
| 53 |
-
docker_cpu_limit: float = 1.0
|
| 54 |
-
docker_network_disabled: bool = True
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
@lru_cache
|
| 58 |
-
def get_settings() -> Settings:
|
| 59 |
-
return Settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/core/dependencies.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
from fastapi import Depends, HTTPException, status
|
| 2 |
-
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 3 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
-
|
| 5 |
-
from backend.core.security import decode_access_token
|
| 6 |
-
from backend.db.models import User
|
| 7 |
-
from backend.db.session import get_db
|
| 8 |
-
from backend.memory.workflow_repository import WorkflowRepository
|
| 9 |
-
|
| 10 |
-
security = HTTPBearer(auto_error=False)
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
async def get_repository(db: AsyncSession = Depends(get_db)) -> WorkflowRepository:
|
| 14 |
-
return WorkflowRepository(db)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
async def get_current_user(
|
| 18 |
-
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
| 19 |
-
repo: WorkflowRepository = Depends(get_repository),
|
| 20 |
-
) -> User:
|
| 21 |
-
if credentials is None or not credentials.credentials:
|
| 22 |
-
raise HTTPException(
|
| 23 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 24 |
-
detail="Not authenticated",
|
| 25 |
-
headers={"WWW-Authenticate": "Bearer"},
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
user_id_str = decode_access_token(credentials.credentials)
|
| 29 |
-
if user_id_str is None:
|
| 30 |
-
raise HTTPException(
|
| 31 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 32 |
-
detail="Invalid or expired token",
|
| 33 |
-
headers={"WWW-Authenticate": "Bearer"},
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
try:
|
| 37 |
-
user_id = int(user_id_str)
|
| 38 |
-
except ValueError as exc:
|
| 39 |
-
raise HTTPException(
|
| 40 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 41 |
-
detail="Invalid token subject",
|
| 42 |
-
) from exc
|
| 43 |
-
|
| 44 |
-
user = await repo.get_user_by_id(user_id)
|
| 45 |
-
if user is None:
|
| 46 |
-
raise HTTPException(
|
| 47 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 48 |
-
detail="User not found",
|
| 49 |
-
)
|
| 50 |
-
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/core/encryption.py
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 1 |
-
import base64
|
| 2 |
-
import hashlib
|
| 3 |
-
|
| 4 |
-
from cryptography.fernet import Fernet, InvalidToken
|
| 5 |
-
|
| 6 |
-
from backend.core.config import get_settings
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _fernet() -> Fernet:
|
| 10 |
-
settings = get_settings()
|
| 11 |
-
key_material = hashlib.sha256(settings.encryption_key.encode()).digest()
|
| 12 |
-
fernet_key = base64.urlsafe_b64encode(key_material)
|
| 13 |
-
return Fernet(fernet_key)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def encrypt_value(value: str) -> str:
|
| 17 |
-
return _fernet().encrypt(value.encode()).decode()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def decrypt_value(encrypted_value: str) -> str:
|
| 21 |
-
try:
|
| 22 |
-
return _fernet().decrypt(encrypted_value.encode()).decode()
|
| 23 |
-
except InvalidToken as exc:
|
| 24 |
-
raise ValueError("Failed to decrypt credential") from exc
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def redact_secret(value: str, visible_chars: int = 4) -> str:
|
| 28 |
-
if len(value) <= visible_chars:
|
| 29 |
-
return "*" * len(value)
|
| 30 |
-
return f"{'*' * (len(value) - visible_chars)}{value[-visible_chars:]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/core/security.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
from datetime import UTC, datetime, timedelta
|
| 2 |
-
from typing import Any
|
| 3 |
-
|
| 4 |
-
from jose import JWTError, jwt
|
| 5 |
-
from passlib.context import CryptContext
|
| 6 |
-
|
| 7 |
-
from backend.core.config import get_settings
|
| 8 |
-
|
| 9 |
-
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def hash_password(password: str) -> str:
|
| 13 |
-
return pwd_context.hash(password)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 17 |
-
return pwd_context.verify(plain_password, hashed_password)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
|
| 21 |
-
settings = get_settings()
|
| 22 |
-
expire = datetime.now(UTC) + (
|
| 23 |
-
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
| 24 |
-
)
|
| 25 |
-
payload: dict[str, Any] = {"sub": subject, "exp": expire}
|
| 26 |
-
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def decode_access_token(token: str) -> str | None:
|
| 30 |
-
settings = get_settings()
|
| 31 |
-
try:
|
| 32 |
-
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
| 33 |
-
subject = payload.get("sub")
|
| 34 |
-
return str(subject) if subject else None
|
| 35 |
-
except JWTError:
|
| 36 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/db/__init__.py
DELETED
|
File without changes
|
backend/db/migrations/env.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
from logging.config import fileConfig
|
| 2 |
-
|
| 3 |
-
from alembic import context
|
| 4 |
-
from sqlalchemy import engine_from_config, pool
|
| 5 |
-
|
| 6 |
-
from backend.core.config import get_settings
|
| 7 |
-
from backend.db.models import Base
|
| 8 |
-
|
| 9 |
-
config = context.config
|
| 10 |
-
if config.config_file_name is not None:
|
| 11 |
-
fileConfig(config.config_file_name)
|
| 12 |
-
|
| 13 |
-
target_metadata = Base.metadata
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def get_url() -> str:
|
| 17 |
-
return get_settings().database_url.replace("+asyncpg", "")
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def run_migrations_offline() -> None:
|
| 21 |
-
url = get_url()
|
| 22 |
-
context.configure(
|
| 23 |
-
url=url,
|
| 24 |
-
target_metadata=target_metadata,
|
| 25 |
-
literal_binds=True,
|
| 26 |
-
dialect_opts={"paramstyle": "named"},
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
with context.begin_transaction():
|
| 30 |
-
context.run_migrations()
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def run_migrations_online() -> None:
|
| 34 |
-
configuration = config.get_section(config.config_ini_section) or {}
|
| 35 |
-
configuration["sqlalchemy.url"] = get_url()
|
| 36 |
-
connectable = engine_from_config(
|
| 37 |
-
configuration,
|
| 38 |
-
prefix="sqlalchemy.",
|
| 39 |
-
poolclass=pool.NullPool,
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
with connectable.connect() as connection:
|
| 43 |
-
context.configure(connection=connection, target_metadata=target_metadata)
|
| 44 |
-
|
| 45 |
-
with context.begin_transaction():
|
| 46 |
-
context.run_migrations()
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
if context.is_offline_mode():
|
| 50 |
-
run_migrations_offline()
|
| 51 |
-
else:
|
| 52 |
-
run_migrations_online()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/db/migrations/versions/001_initial_schema.py
DELETED
|
@@ -1,138 +0,0 @@
|
|
| 1 |
-
"""Initial schema
|
| 2 |
-
|
| 3 |
-
Revision ID: 001_initial
|
| 4 |
-
Revises:
|
| 5 |
-
Create Date: 2026-08-29
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from typing import Sequence, Union
|
| 9 |
-
|
| 10 |
-
import sqlalchemy as sa
|
| 11 |
-
from alembic import op
|
| 12 |
-
from sqlalchemy.dialects import postgresql
|
| 13 |
-
|
| 14 |
-
revision: str = "001_initial"
|
| 15 |
-
down_revision: Union[str, None] = None
|
| 16 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 17 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def upgrade() -> None:
|
| 21 |
-
op.create_table(
|
| 22 |
-
"users",
|
| 23 |
-
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 24 |
-
sa.Column("email", sa.String(length=255), nullable=False),
|
| 25 |
-
sa.Column("username", sa.String(length=100), nullable=False),
|
| 26 |
-
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
| 27 |
-
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 28 |
-
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 29 |
-
sa.PrimaryKeyConstraint("id"),
|
| 30 |
-
)
|
| 31 |
-
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
| 32 |
-
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
| 33 |
-
|
| 34 |
-
op.create_table(
|
| 35 |
-
"workflows",
|
| 36 |
-
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 37 |
-
sa.Column("workflow_id", sa.String(length=64), nullable=False),
|
| 38 |
-
sa.Column("user_id", sa.Integer(), nullable=False),
|
| 39 |
-
sa.Column("user_prompt", sa.Text(), nullable=False),
|
| 40 |
-
sa.Column("status", sa.String(length=32), nullable=False),
|
| 41 |
-
sa.Column("planner_output", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 42 |
-
sa.Column("llm_provider", sa.String(length=64), nullable=True),
|
| 43 |
-
sa.Column("llm_model", sa.String(length=128), nullable=True),
|
| 44 |
-
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 45 |
-
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
| 46 |
-
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
| 47 |
-
sa.PrimaryKeyConstraint("id"),
|
| 48 |
-
)
|
| 49 |
-
op.create_index(op.f("ix_workflows_user_id"), "workflows", ["user_id"], unique=False)
|
| 50 |
-
op.create_index(op.f("ix_workflows_workflow_id"), "workflows", ["workflow_id"], unique=True)
|
| 51 |
-
|
| 52 |
-
op.create_table(
|
| 53 |
-
"api_credentials",
|
| 54 |
-
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 55 |
-
sa.Column("user_id", sa.Integer(), nullable=False),
|
| 56 |
-
sa.Column("provider", sa.String(length=64), nullable=False),
|
| 57 |
-
sa.Column("encrypted_api_key", sa.Text(), nullable=False),
|
| 58 |
-
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 59 |
-
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 60 |
-
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
| 61 |
-
sa.PrimaryKeyConstraint("id"),
|
| 62 |
-
)
|
| 63 |
-
op.create_index(op.f("ix_api_credentials_user_id"), "api_credentials", ["user_id"], unique=False)
|
| 64 |
-
|
| 65 |
-
op.create_table(
|
| 66 |
-
"tasks",
|
| 67 |
-
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 68 |
-
sa.Column("workflow_id", sa.Integer(), nullable=False),
|
| 69 |
-
sa.Column("task_key", sa.String(length=64), nullable=False),
|
| 70 |
-
sa.Column("task_type", sa.String(length=64), nullable=False),
|
| 71 |
-
sa.Column("status", sa.String(length=32), nullable=False),
|
| 72 |
-
sa.Column("depends_on", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 73 |
-
sa.Column("output", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 74 |
-
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
| 75 |
-
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
| 76 |
-
sa.ForeignKeyConstraint(["workflow_id"], ["workflows.id"]),
|
| 77 |
-
sa.PrimaryKeyConstraint("id"),
|
| 78 |
-
)
|
| 79 |
-
op.create_index(op.f("ix_tasks_workflow_id"), "tasks", ["workflow_id"], unique=False)
|
| 80 |
-
|
| 81 |
-
op.create_table(
|
| 82 |
-
"attempts",
|
| 83 |
-
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 84 |
-
sa.Column("workflow_id", sa.Integer(), nullable=False),
|
| 85 |
-
sa.Column("task_id", sa.Integer(), nullable=True),
|
| 86 |
-
sa.Column("attempt_number", sa.Integer(), nullable=False),
|
| 87 |
-
sa.Column("generated_code", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 88 |
-
sa.Column("generated_tests", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 89 |
-
sa.Column("static_analysis_result", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 90 |
-
sa.Column("stdout", sa.Text(), nullable=True),
|
| 91 |
-
sa.Column("stderr", sa.Text(), nullable=True),
|
| 92 |
-
sa.Column("exit_code", sa.Integer(), nullable=True),
|
| 93 |
-
sa.Column("result", sa.String(length=32), nullable=True),
|
| 94 |
-
sa.Column("error", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 95 |
-
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 96 |
-
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
|
| 97 |
-
sa.ForeignKeyConstraint(["workflow_id"], ["workflows.id"]),
|
| 98 |
-
sa.PrimaryKeyConstraint("id"),
|
| 99 |
-
)
|
| 100 |
-
op.create_index(op.f("ix_attempts_task_id"), "attempts", ["task_id"], unique=False)
|
| 101 |
-
op.create_index(op.f("ix_attempts_workflow_id"), "attempts", ["workflow_id"], unique=False)
|
| 102 |
-
|
| 103 |
-
op.create_table(
|
| 104 |
-
"events",
|
| 105 |
-
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
| 106 |
-
sa.Column("workflow_id", sa.Integer(), nullable=False),
|
| 107 |
-
sa.Column("task_id", sa.Integer(), nullable=True),
|
| 108 |
-
sa.Column("event_type", sa.String(length=64), nullable=False),
|
| 109 |
-
sa.Column("message", sa.Text(), nullable=False),
|
| 110 |
-
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 111 |
-
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
| 112 |
-
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
|
| 113 |
-
sa.ForeignKeyConstraint(["workflow_id"], ["workflows.id"]),
|
| 114 |
-
sa.PrimaryKeyConstraint("id"),
|
| 115 |
-
)
|
| 116 |
-
op.create_index(op.f("ix_events_event_type"), "events", ["event_type"], unique=False)
|
| 117 |
-
op.create_index(op.f("ix_events_timestamp"), "events", ["timestamp"], unique=False)
|
| 118 |
-
op.create_index(op.f("ix_events_workflow_id"), "events", ["workflow_id"], unique=False)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def downgrade() -> None:
|
| 122 |
-
op.drop_index(op.f("ix_events_workflow_id"), table_name="events")
|
| 123 |
-
op.drop_index(op.f("ix_events_timestamp"), table_name="events")
|
| 124 |
-
op.drop_index(op.f("ix_events_event_type"), table_name="events")
|
| 125 |
-
op.drop_table("events")
|
| 126 |
-
op.drop_index(op.f("ix_attempts_workflow_id"), table_name="attempts")
|
| 127 |
-
op.drop_index(op.f("ix_attempts_task_id"), table_name="attempts")
|
| 128 |
-
op.drop_table("attempts")
|
| 129 |
-
op.drop_index(op.f("ix_tasks_workflow_id"), table_name="tasks")
|
| 130 |
-
op.drop_table("tasks")
|
| 131 |
-
op.drop_index(op.f("ix_api_credentials_user_id"), table_name="api_credentials")
|
| 132 |
-
op.drop_table("api_credentials")
|
| 133 |
-
op.drop_index(op.f("ix_workflows_workflow_id"), table_name="workflows")
|
| 134 |
-
op.drop_index(op.f("ix_workflows_user_id"), table_name="workflows")
|
| 135 |
-
op.drop_table("workflows")
|
| 136 |
-
op.drop_index(op.f("ix_users_username"), table_name="users")
|
| 137 |
-
op.drop_index(op.f("ix_users_email"), table_name="users")
|
| 138 |
-
op.drop_table("users")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/db/migrations/versions/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Alembic migrations for AutoForge."""
|
|
|
|
|
|
backend/db/models/__init__.py
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 1 |
-
from datetime import datetime
|
| 2 |
-
from enum import StrEnum
|
| 3 |
-
|
| 4 |
-
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func, JSON
|
| 5 |
-
from sqlalchemy.dialects.postgresql import JSONB
|
| 6 |
-
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
| 7 |
-
|
| 8 |
-
JSON_TYPE = JSON().with_variant(JSONB, "postgresql")
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class Base(DeclarativeBase):
|
| 12 |
-
pass
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class WorkflowStatus(StrEnum):
|
| 16 |
-
CREATED = "CREATED"
|
| 17 |
-
RUNNING = "RUNNING"
|
| 18 |
-
SUCCESS = "SUCCESS"
|
| 19 |
-
FAILED = "FAILED"
|
| 20 |
-
CANCELLED = "CANCELLED"
|
| 21 |
-
CANCELLING = "CANCELLING"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
class TaskStatus(StrEnum):
|
| 25 |
-
CREATED = "CREATED"
|
| 26 |
-
READY = "READY"
|
| 27 |
-
RUNNING = "RUNNING"
|
| 28 |
-
SUCCESS = "SUCCESS"
|
| 29 |
-
FAILED = "FAILED"
|
| 30 |
-
RETRYING = "RETRYING"
|
| 31 |
-
CANCELLED = "CANCELLED"
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class User(Base):
|
| 35 |
-
__tablename__ = "users"
|
| 36 |
-
|
| 37 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 38 |
-
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
| 39 |
-
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
| 40 |
-
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 41 |
-
created_at: Mapped[datetime] = mapped_column(
|
| 42 |
-
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 43 |
-
)
|
| 44 |
-
updated_at: Mapped[datetime] = mapped_column(
|
| 45 |
-
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
workflows: Mapped[list["Workflow"]] = relationship(back_populates="user")
|
| 49 |
-
api_credentials: Mapped[list["ApiCredential"]] = relationship(back_populates="user")
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
class Workflow(Base):
|
| 53 |
-
__tablename__ = "workflows"
|
| 54 |
-
|
| 55 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 56 |
-
workflow_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
| 57 |
-
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
| 58 |
-
user_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
| 59 |
-
status: Mapped[str] = mapped_column(String(32), nullable=False, default=WorkflowStatus.CREATED)
|
| 60 |
-
planner_output: Mapped[dict | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 61 |
-
llm_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 62 |
-
llm_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
| 63 |
-
created_at: Mapped[datetime] = mapped_column(
|
| 64 |
-
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 65 |
-
)
|
| 66 |
-
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 67 |
-
|
| 68 |
-
user: Mapped["User"] = relationship(back_populates="workflows")
|
| 69 |
-
tasks: Mapped[list["Task"]] = relationship(back_populates="workflow", cascade="all, delete-orphan")
|
| 70 |
-
attempts: Mapped[list["Attempt"]] = relationship(
|
| 71 |
-
back_populates="workflow", cascade="all, delete-orphan"
|
| 72 |
-
)
|
| 73 |
-
events: Mapped[list["Event"]] = relationship(
|
| 74 |
-
back_populates="workflow", cascade="all, delete-orphan"
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
class Task(Base):
|
| 79 |
-
__tablename__ = "tasks"
|
| 80 |
-
|
| 81 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 82 |
-
workflow_id: Mapped[int] = mapped_column(ForeignKey("workflows.id"), nullable=False, index=True)
|
| 83 |
-
task_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 84 |
-
task_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 85 |
-
status: Mapped[str] = mapped_column(String(32), nullable=False, default=TaskStatus.CREATED)
|
| 86 |
-
depends_on: Mapped[list | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 87 |
-
output: Mapped[dict | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 88 |
-
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 89 |
-
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 90 |
-
|
| 91 |
-
workflow: Mapped["Workflow"] = relationship(back_populates="tasks")
|
| 92 |
-
attempts: Mapped[list["Attempt"]] = relationship(back_populates="task")
|
| 93 |
-
events: Mapped[list["Event"]] = relationship(back_populates="task")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
class Attempt(Base):
|
| 97 |
-
__tablename__ = "attempts"
|
| 98 |
-
|
| 99 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 100 |
-
workflow_id: Mapped[int] = mapped_column(ForeignKey("workflows.id"), nullable=False, index=True)
|
| 101 |
-
task_id: Mapped[int | None] = mapped_column(ForeignKey("tasks.id"), nullable=True, index=True)
|
| 102 |
-
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
| 103 |
-
generated_code: Mapped[dict | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 104 |
-
generated_tests: Mapped[dict | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 105 |
-
static_analysis_result: Mapped[dict | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 106 |
-
stdout: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 107 |
-
stderr: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 108 |
-
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 109 |
-
result: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
| 110 |
-
error: Mapped[dict | None] = mapped_column(JSON_TYPE, nullable=True)
|
| 111 |
-
created_at: Mapped[datetime] = mapped_column(
|
| 112 |
-
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 113 |
-
)
|
| 114 |
-
|
| 115 |
-
workflow: Mapped["Workflow"] = relationship(back_populates="attempts")
|
| 116 |
-
task: Mapped["Task | None"] = relationship(back_populates="attempts")
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
class Event(Base):
|
| 120 |
-
__tablename__ = "events"
|
| 121 |
-
|
| 122 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 123 |
-
workflow_id: Mapped[int] = mapped_column(ForeignKey("workflows.id"), nullable=False, index=True)
|
| 124 |
-
task_id: Mapped[int | None] = mapped_column(ForeignKey("tasks.id"), nullable=True, index=True)
|
| 125 |
-
event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
| 126 |
-
message: Mapped[str] = mapped_column(Text, nullable=False)
|
| 127 |
-
event_metadata: Mapped[dict | None] = mapped_column("metadata", JSON_TYPE, nullable=True)
|
| 128 |
-
timestamp: Mapped[datetime] = mapped_column(
|
| 129 |
-
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
workflow: Mapped["Workflow"] = relationship(back_populates="events")
|
| 133 |
-
task: Mapped["Task | None"] = relationship(back_populates="events")
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
class ApiCredential(Base):
|
| 137 |
-
__tablename__ = "api_credentials"
|
| 138 |
-
|
| 139 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 140 |
-
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
| 141 |
-
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 142 |
-
encrypted_api_key: Mapped[str] = mapped_column(Text, nullable=False)
|
| 143 |
-
created_at: Mapped[datetime] = mapped_column(
|
| 144 |
-
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 145 |
-
)
|
| 146 |
-
updated_at: Mapped[datetime] = mapped_column(
|
| 147 |
-
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
| 148 |
-
)
|
| 149 |
-
|
| 150 |
-
user: Mapped["User"] = relationship(back_populates="api_credentials")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/db/session.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from collections.abc import AsyncGenerator
|
| 3 |
-
|
| 4 |
-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 5 |
-
|
| 6 |
-
from backend.core.config import get_settings
|
| 7 |
-
|
| 8 |
-
logger = logging.getLogger(__name__)
|
| 9 |
-
settings = get_settings()
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def _create_engine_safely(url: str):
|
| 13 |
-
try:
|
| 14 |
-
if url.startswith("sqlite"):
|
| 15 |
-
return create_async_engine(
|
| 16 |
-
url,
|
| 17 |
-
echo=False,
|
| 18 |
-
connect_args={"check_same_thread": False},
|
| 19 |
-
)
|
| 20 |
-
return create_async_engine(
|
| 21 |
-
url,
|
| 22 |
-
echo=False,
|
| 23 |
-
pool_pre_ping=True,
|
| 24 |
-
)
|
| 25 |
-
except Exception as exc:
|
| 26 |
-
logger.error("Failed to create engine for %s, falling back to SQLite: %s", url, exc)
|
| 27 |
-
return create_async_engine(
|
| 28 |
-
"sqlite+aiosqlite:////tmp/autoforge.db",
|
| 29 |
-
echo=False,
|
| 30 |
-
connect_args={"check_same_thread": False},
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
engine = _create_engine_safely(settings.database_url)
|
| 35 |
-
|
| 36 |
-
AsyncSessionLocal = async_sessionmaker(
|
| 37 |
-
engine,
|
| 38 |
-
class_=AsyncSession,
|
| 39 |
-
expire_on_commit=False,
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 44 |
-
async with AsyncSessionLocal() as session:
|
| 45 |
-
try:
|
| 46 |
-
yield session
|
| 47 |
-
await session.commit()
|
| 48 |
-
except Exception:
|
| 49 |
-
await session.rollback()
|
| 50 |
-
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/events/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Event publishing utilities."""
|
|
|
|
|
|
backend/events/publisher.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
from sqlalchemy import select
|
| 2 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
-
|
| 4 |
-
from backend.db.models import Event
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class EventPublisher:
|
| 8 |
-
"""Persists workflow events to PostgreSQL."""
|
| 9 |
-
|
| 10 |
-
def __init__(self, db: AsyncSession) -> None:
|
| 11 |
-
self.db = db
|
| 12 |
-
|
| 13 |
-
async def publish(
|
| 14 |
-
self,
|
| 15 |
-
*,
|
| 16 |
-
workflow_db_id: int,
|
| 17 |
-
public_workflow_id: str,
|
| 18 |
-
event_type: str,
|
| 19 |
-
message: str,
|
| 20 |
-
task_id: int | None = None,
|
| 21 |
-
task_key: str | None = None,
|
| 22 |
-
metadata: dict | None = None,
|
| 23 |
-
) -> Event:
|
| 24 |
-
event_metadata = {"workflow_id": public_workflow_id}
|
| 25 |
-
if task_key:
|
| 26 |
-
event_metadata["task_key"] = task_key
|
| 27 |
-
if metadata:
|
| 28 |
-
event_metadata.update(metadata)
|
| 29 |
-
|
| 30 |
-
event = Event(
|
| 31 |
-
workflow_id=workflow_db_id,
|
| 32 |
-
task_id=task_id,
|
| 33 |
-
event_type=event_type,
|
| 34 |
-
message=message,
|
| 35 |
-
event_metadata=event_metadata,
|
| 36 |
-
)
|
| 37 |
-
self.db.add(event)
|
| 38 |
-
await self.db.flush()
|
| 39 |
-
await self.db.refresh(event)
|
| 40 |
-
return event
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/execution/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Docker sandbox execution — implemented in Phase 2+."""
|
|
|
|
|
|
backend/execution/docker_runner.py
DELETED
|
@@ -1,265 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import os
|
| 3 |
-
import time
|
| 4 |
-
import uuid
|
| 5 |
-
from abc import ABC, abstractmethod
|
| 6 |
-
from pathlib import Path, PureWindowsPath
|
| 7 |
-
|
| 8 |
-
from backend.core.config import get_settings
|
| 9 |
-
from backend.execution.sandbox import ExecutionResult
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def _docker_volume_path(workspace: Path) -> str:
|
| 13 |
-
"""Convert a host path to a Docker-compatible volume mount source."""
|
| 14 |
-
resolved = workspace.resolve()
|
| 15 |
-
if os.name == "nt":
|
| 16 |
-
path = PureWindowsPath(resolved)
|
| 17 |
-
drive = path.drive.rstrip(":").lower()
|
| 18 |
-
rest = str(path.relative_to(path.anchor)).replace("\\", "/")
|
| 19 |
-
return f"/{drive}/{rest}"
|
| 20 |
-
return resolved.as_posix()
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _build_container_test_command(workspace: Path, *, network_disabled: bool) -> str:
|
| 24 |
-
requirements = workspace / "requirements.txt"
|
| 25 |
-
if (
|
| 26 |
-
not network_disabled
|
| 27 |
-
and requirements.exists()
|
| 28 |
-
and requirements.read_text(encoding="utf-8").strip()
|
| 29 |
-
):
|
| 30 |
-
return "pip install --no-cache-dir -r requirements.txt -q && python -m pytest -v"
|
| 31 |
-
return "python -m pytest -v"
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class DockerRunnerBase(ABC):
|
| 35 |
-
@abstractmethod
|
| 36 |
-
async def run_tests(
|
| 37 |
-
self,
|
| 38 |
-
workspace: Path,
|
| 39 |
-
*,
|
| 40 |
-
timeout: int | None = None,
|
| 41 |
-
) -> ExecutionResult:
|
| 42 |
-
"""Run project tests inside an isolated container."""
|
| 43 |
-
|
| 44 |
-
@abstractmethod
|
| 45 |
-
async def cleanup(self, container_id: str | None = None) -> None:
|
| 46 |
-
"""Remove container resources."""
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
class DockerCliRunner(DockerRunnerBase):
|
| 50 |
-
"""Execute generated projects via the Docker CLI — never on the host."""
|
| 51 |
-
|
| 52 |
-
def __init__(
|
| 53 |
-
self,
|
| 54 |
-
*,
|
| 55 |
-
docker_host: str | None = None,
|
| 56 |
-
image: str | None = None,
|
| 57 |
-
memory_limit: str | None = None,
|
| 58 |
-
cpu_limit: float | None = None,
|
| 59 |
-
network_disabled: bool | None = None,
|
| 60 |
-
) -> None:
|
| 61 |
-
settings = get_settings()
|
| 62 |
-
self.docker_host = docker_host or settings.docker_host
|
| 63 |
-
self.image = image or settings.docker_image
|
| 64 |
-
self.memory_limit = memory_limit or settings.docker_memory_limit
|
| 65 |
-
self.cpu_limit = cpu_limit or settings.docker_cpu_limit
|
| 66 |
-
self.network_disabled = (
|
| 67 |
-
network_disabled
|
| 68 |
-
if network_disabled is not None
|
| 69 |
-
else settings.docker_network_disabled
|
| 70 |
-
)
|
| 71 |
-
self.default_timeout = settings.default_timeout
|
| 72 |
-
self._active_containers: set[str] = set()
|
| 73 |
-
|
| 74 |
-
async def run_tests(
|
| 75 |
-
self,
|
| 76 |
-
workspace: Path,
|
| 77 |
-
*,
|
| 78 |
-
timeout: int | None = None,
|
| 79 |
-
) -> ExecutionResult:
|
| 80 |
-
timeout = timeout or self.default_timeout
|
| 81 |
-
container_name = f"autoforge_{uuid.uuid4().hex[:12]}"
|
| 82 |
-
workspace_mount = _docker_volume_path(workspace)
|
| 83 |
-
test_command = _build_container_test_command(workspace, network_disabled=self.network_disabled)
|
| 84 |
-
|
| 85 |
-
command = [
|
| 86 |
-
"docker",
|
| 87 |
-
"run",
|
| 88 |
-
"--name",
|
| 89 |
-
container_name,
|
| 90 |
-
"--rm",
|
| 91 |
-
"--memory",
|
| 92 |
-
self.memory_limit,
|
| 93 |
-
"--cpus",
|
| 94 |
-
str(self.cpu_limit),
|
| 95 |
-
"-v",
|
| 96 |
-
f"{workspace_mount}:/workspace:ro",
|
| 97 |
-
"-w",
|
| 98 |
-
"/workspace",
|
| 99 |
-
]
|
| 100 |
-
if self.network_disabled:
|
| 101 |
-
command.append("--network")
|
| 102 |
-
command.append("none")
|
| 103 |
-
|
| 104 |
-
command.extend([self.image, "bash", "-lc", test_command])
|
| 105 |
-
|
| 106 |
-
start = time.perf_counter()
|
| 107 |
-
timed_out = False
|
| 108 |
-
stdout = ""
|
| 109 |
-
stderr = ""
|
| 110 |
-
exit_code = 1
|
| 111 |
-
container_id = container_name
|
| 112 |
-
|
| 113 |
-
try:
|
| 114 |
-
process = await asyncio.create_subprocess_exec(
|
| 115 |
-
*command,
|
| 116 |
-
stdout=asyncio.subprocess.PIPE,
|
| 117 |
-
stderr=asyncio.subprocess.PIPE,
|
| 118 |
-
)
|
| 119 |
-
self._active_containers.add(container_name)
|
| 120 |
-
try:
|
| 121 |
-
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
| 122 |
-
process.communicate(),
|
| 123 |
-
timeout=timeout,
|
| 124 |
-
)
|
| 125 |
-
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
| 126 |
-
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
| 127 |
-
exit_code = process.returncode or 0
|
| 128 |
-
except asyncio.TimeoutError:
|
| 129 |
-
timed_out = True
|
| 130 |
-
stderr = f"Execution timed out after {timeout} seconds"
|
| 131 |
-
exit_code = 124
|
| 132 |
-
await self._force_remove_container(container_name)
|
| 133 |
-
finally:
|
| 134 |
-
self._active_containers.discard(container_name)
|
| 135 |
-
except FileNotFoundError as exc:
|
| 136 |
-
stderr = "Docker CLI not found. Generated code cannot run without Docker."
|
| 137 |
-
exit_code = 127
|
| 138 |
-
return ExecutionResult(
|
| 139 |
-
stdout=stdout,
|
| 140 |
-
stderr=stderr,
|
| 141 |
-
exit_code=exit_code,
|
| 142 |
-
execution_time=time.perf_counter() - start,
|
| 143 |
-
timed_out=False,
|
| 144 |
-
container_id=container_id,
|
| 145 |
-
container_build_status="failed",
|
| 146 |
-
container_start_status="failed",
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
elapsed = time.perf_counter() - start
|
| 150 |
-
return ExecutionResult(
|
| 151 |
-
stdout=stdout,
|
| 152 |
-
stderr=stderr,
|
| 153 |
-
exit_code=exit_code,
|
| 154 |
-
execution_time=elapsed,
|
| 155 |
-
timed_out=timed_out,
|
| 156 |
-
container_id=container_id,
|
| 157 |
-
container_build_status="success",
|
| 158 |
-
container_start_status="started" if not timed_out else "timeout",
|
| 159 |
-
)
|
| 160 |
-
|
| 161 |
-
async def cleanup(self, container_id: str | None = None) -> None:
|
| 162 |
-
if container_id:
|
| 163 |
-
await self._force_remove_container(container_id)
|
| 164 |
-
return
|
| 165 |
-
for cid in list(self._active_containers):
|
| 166 |
-
await self._force_remove_container(cid)
|
| 167 |
-
|
| 168 |
-
async def _force_remove_container(self, container_name: str) -> None:
|
| 169 |
-
process = await asyncio.create_subprocess_exec(
|
| 170 |
-
"docker",
|
| 171 |
-
"rm",
|
| 172 |
-
"-f",
|
| 173 |
-
container_name,
|
| 174 |
-
stdout=asyncio.subprocess.DEVNULL,
|
| 175 |
-
stderr=asyncio.subprocess.DEVNULL,
|
| 176 |
-
)
|
| 177 |
-
await process.wait()
|
| 178 |
-
self._active_containers.discard(container_name)
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
class FakeDockerRunner(DockerRunnerBase):
|
| 182 |
-
"""Test double that simulates container execution without Docker."""
|
| 183 |
-
|
| 184 |
-
def __init__(
|
| 185 |
-
self,
|
| 186 |
-
*,
|
| 187 |
-
exit_code: int = 0,
|
| 188 |
-
stdout: str = "===== 2 passed in 0.01s =====",
|
| 189 |
-
stderr: str = "",
|
| 190 |
-
timed_out: bool = False,
|
| 191 |
-
delay: float = 0,
|
| 192 |
-
should_fail_start: bool = False,
|
| 193 |
-
) -> None:
|
| 194 |
-
self.exit_code = exit_code
|
| 195 |
-
self.stdout = stdout
|
| 196 |
-
self.stderr = stderr
|
| 197 |
-
self.timed_out = timed_out
|
| 198 |
-
self.delay = delay
|
| 199 |
-
self.should_fail_start = should_fail_start
|
| 200 |
-
self.fail_remaining = 0
|
| 201 |
-
self.containers_started: list[str] = []
|
| 202 |
-
self.cleaned_up: list[str] = []
|
| 203 |
-
|
| 204 |
-
def with_failures(self, count: int) -> "FakeDockerRunner":
|
| 205 |
-
self.fail_remaining = count
|
| 206 |
-
return self
|
| 207 |
-
|
| 208 |
-
async def run_tests(
|
| 209 |
-
self,
|
| 210 |
-
workspace: Path,
|
| 211 |
-
*,
|
| 212 |
-
timeout: int | None = None,
|
| 213 |
-
) -> ExecutionResult:
|
| 214 |
-
if self.should_fail_start:
|
| 215 |
-
return ExecutionResult(
|
| 216 |
-
stdout="",
|
| 217 |
-
stderr="Docker build failed",
|
| 218 |
-
exit_code=1,
|
| 219 |
-
execution_time=0,
|
| 220 |
-
container_build_status="failed",
|
| 221 |
-
container_start_status="failed",
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
if self.delay:
|
| 225 |
-
await asyncio.sleep(self.delay)
|
| 226 |
-
|
| 227 |
-
container_id = f"fake_{uuid.uuid4().hex[:8]}"
|
| 228 |
-
self.containers_started.append(container_id)
|
| 229 |
-
|
| 230 |
-
if self.fail_remaining > 0:
|
| 231 |
-
self.fail_remaining -= 1
|
| 232 |
-
return ExecutionResult(
|
| 233 |
-
stdout="===== 1 failed in 0.01s =====",
|
| 234 |
-
stderr="AssertionError: test failed",
|
| 235 |
-
exit_code=1,
|
| 236 |
-
execution_time=self.delay,
|
| 237 |
-
container_id=container_id,
|
| 238 |
-
container_build_status="success",
|
| 239 |
-
container_start_status="started",
|
| 240 |
-
)
|
| 241 |
-
|
| 242 |
-
if self.timed_out:
|
| 243 |
-
return ExecutionResult(
|
| 244 |
-
stdout="",
|
| 245 |
-
stderr="Execution timed out",
|
| 246 |
-
exit_code=124,
|
| 247 |
-
execution_time=float(timeout or 1),
|
| 248 |
-
timed_out=True,
|
| 249 |
-
container_id=container_id,
|
| 250 |
-
container_build_status="success",
|
| 251 |
-
container_start_status="timeout",
|
| 252 |
-
)
|
| 253 |
-
|
| 254 |
-
return ExecutionResult(
|
| 255 |
-
stdout=self.stdout,
|
| 256 |
-
stderr=self.stderr,
|
| 257 |
-
exit_code=self.exit_code,
|
| 258 |
-
execution_time=self.delay,
|
| 259 |
-
container_id=container_id,
|
| 260 |
-
container_build_status="success",
|
| 261 |
-
container_start_status="started",
|
| 262 |
-
)
|
| 263 |
-
|
| 264 |
-
async def cleanup(self, container_id: str | None = None) -> None:
|
| 265 |
-
self.cleaned_up.append(container_id or "all")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/execution/result_parser.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
import re
|
| 2 |
-
from dataclasses import dataclass
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
@dataclass
|
| 6 |
-
class TestSummary:
|
| 7 |
-
passed: int
|
| 8 |
-
failed: int
|
| 9 |
-
total: int
|
| 10 |
-
|
| 11 |
-
@property
|
| 12 |
-
def success(self) -> bool:
|
| 13 |
-
return self.failed == 0 and self.total > 0
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def parse_pytest_output(stdout: str, stderr: str) -> TestSummary:
|
| 17 |
-
combined = f"{stdout}\n{stderr}"
|
| 18 |
-
match = re.search(r"=+\s+(\d+) passed(?:,\s+(\d+) failed)?", combined)
|
| 19 |
-
if match:
|
| 20 |
-
passed = int(match.group(1))
|
| 21 |
-
failed = int(match.group(2) or 0)
|
| 22 |
-
return TestSummary(passed=passed, failed=failed, total=passed + failed)
|
| 23 |
-
|
| 24 |
-
failed_match = re.search(r"=+\s+(\d+) failed", combined)
|
| 25 |
-
if failed_match:
|
| 26 |
-
failed = int(failed_match.group(1))
|
| 27 |
-
return TestSummary(passed=0, failed=failed, total=failed)
|
| 28 |
-
|
| 29 |
-
return TestSummary(passed=0, failed=0, total=0)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def parse_verification_result(stdout: str, stderr: str, exit_code: int) -> dict:
|
| 33 |
-
summary = parse_pytest_output(stdout, stderr)
|
| 34 |
-
return {
|
| 35 |
-
"tests_passed": summary.passed,
|
| 36 |
-
"tests_failed": summary.failed,
|
| 37 |
-
"tests_total": summary.total,
|
| 38 |
-
"verification_passed": exit_code == 0 and summary.success,
|
| 39 |
-
"exit_code": exit_code,
|
| 40 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/execution/sandbox.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
from dataclasses import dataclass
|
| 2 |
-
from pathlib import Path
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
@dataclass
|
| 6 |
-
class ExecutionResult:
|
| 7 |
-
stdout: str
|
| 8 |
-
stderr: str
|
| 9 |
-
exit_code: int
|
| 10 |
-
execution_time: float
|
| 11 |
-
timed_out: bool = False
|
| 12 |
-
container_id: str | None = None
|
| 13 |
-
container_build_status: str = "skipped"
|
| 14 |
-
container_start_status: str = "unknown"
|
| 15 |
-
|
| 16 |
-
@property
|
| 17 |
-
def success(self) -> bool:
|
| 18 |
-
return self.exit_code == 0 and not self.timed_out
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def write_project_files(workspace: Path, files: dict[str, dict[str, str]]) -> None:
|
| 22 |
-
workspace.mkdir(parents=True, exist_ok=True)
|
| 23 |
-
for rel_path, meta in files.items():
|
| 24 |
-
target = workspace / rel_path
|
| 25 |
-
target.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
-
target.write_text(meta["content"], encoding="utf-8")
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def cleanup_workspace(workspace: Path) -> None:
|
| 30 |
-
if not workspace.exists():
|
| 31 |
-
return
|
| 32 |
-
for path in sorted(workspace.rglob("*"), reverse=True):
|
| 33 |
-
if path.is_file():
|
| 34 |
-
path.unlink()
|
| 35 |
-
elif path.is_dir():
|
| 36 |
-
path.rmdir()
|
| 37 |
-
if workspace.exists():
|
| 38 |
-
workspace.rmdir()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/execution/verification.py
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
from pathlib import Path
|
| 2 |
-
|
| 3 |
-
from backend.execution.docker_runner import DockerRunnerBase
|
| 4 |
-
from backend.execution.result_parser import parse_verification_result
|
| 5 |
-
from backend.execution.sandbox import ExecutionResult, write_project_files
|
| 6 |
-
from backend.orchestration.errors import TaskExecutionError
|
| 7 |
-
from backend.orchestration.task_context import TaskContext
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class VerificationPipeline:
|
| 11 |
-
"""Code → Static Analysis gate → Docker → Test Execution → Verification."""
|
| 12 |
-
|
| 13 |
-
def __init__(self, docker_runner: DockerRunnerBase) -> None:
|
| 14 |
-
self.docker_runner = docker_runner
|
| 15 |
-
|
| 16 |
-
async def run(self, context: TaskContext) -> dict:
|
| 17 |
-
files = context.all_project_files
|
| 18 |
-
if not files:
|
| 19 |
-
raise TaskExecutionError(
|
| 20 |
-
"No project files available for verification",
|
| 21 |
-
error_type="RUNTIME_ERROR",
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
static_result = context.task_outputs_by_type.get("static_analysis")
|
| 25 |
-
if static_result and static_result.get("status") != "PASS":
|
| 26 |
-
raise TaskExecutionError(
|
| 27 |
-
"Static analysis did not pass; skipping execution",
|
| 28 |
-
error_type="STATIC_ANALYSIS_ERROR",
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
workspace = context.workspace_path / "verify"
|
| 32 |
-
write_project_files(workspace, files)
|
| 33 |
-
|
| 34 |
-
execution: ExecutionResult | None = None
|
| 35 |
-
try:
|
| 36 |
-
execution = await self.docker_runner.run_tests(workspace)
|
| 37 |
-
verification = parse_verification_result(
|
| 38 |
-
execution.stdout,
|
| 39 |
-
execution.stderr,
|
| 40 |
-
execution.exit_code,
|
| 41 |
-
)
|
| 42 |
-
|
| 43 |
-
if execution.timed_out:
|
| 44 |
-
raise TaskExecutionError(
|
| 45 |
-
"Docker execution timed out",
|
| 46 |
-
error_type="TIMEOUT",
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
if not verification["verification_passed"]:
|
| 50 |
-
raise TaskExecutionError(
|
| 51 |
-
f"Verification failed: {verification['tests_failed']} test(s) failed",
|
| 52 |
-
error_type="TEST_FAILURE",
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
return {
|
| 56 |
-
"status": "PASS",
|
| 57 |
-
"stdout": execution.stdout,
|
| 58 |
-
"stderr": execution.stderr,
|
| 59 |
-
"exit_code": execution.exit_code,
|
| 60 |
-
"execution_time": execution.execution_time,
|
| 61 |
-
"timed_out": execution.timed_out,
|
| 62 |
-
"container_build_status": execution.container_build_status,
|
| 63 |
-
"container_start_status": execution.container_start_status,
|
| 64 |
-
"verification": verification,
|
| 65 |
-
}
|
| 66 |
-
finally:
|
| 67 |
-
await self.docker_runner.cleanup(
|
| 68 |
-
execution.container_id if execution else None
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
async def run_verification(context: TaskContext) -> dict:
|
| 73 |
-
docker_runner = context.docker_runner
|
| 74 |
-
if docker_runner is None:
|
| 75 |
-
from backend.execution.docker_runner import DockerCliRunner
|
| 76 |
-
|
| 77 |
-
docker_runner = DockerCliRunner()
|
| 78 |
-
pipeline = VerificationPipeline(docker_runner)
|
| 79 |
-
return await pipeline.run(context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/llm/__init__.py
DELETED
|
File without changes
|
backend/llm/base.py
DELETED
|
@@ -1,48 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
from abc import ABC, abstractmethod
|
| 3 |
-
from typing import Any
|
| 4 |
-
|
| 5 |
-
from backend.llm.types import LLMCostEstimate, LLMResponse
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class LLMProvider(ABC):
|
| 9 |
-
"""Abstract LLM provider interface used by agents and orchestration."""
|
| 10 |
-
|
| 11 |
-
provider_name: str
|
| 12 |
-
|
| 13 |
-
@abstractmethod
|
| 14 |
-
async def generate(
|
| 15 |
-
self,
|
| 16 |
-
prompt: str,
|
| 17 |
-
*,
|
| 18 |
-
system_prompt: str | None = None,
|
| 19 |
-
temperature: float = 0.2,
|
| 20 |
-
max_tokens: int | None = None,
|
| 21 |
-
) -> LLMResponse:
|
| 22 |
-
"""Generate a text completion from the provider."""
|
| 23 |
-
|
| 24 |
-
@abstractmethod
|
| 25 |
-
async def generate_structured(
|
| 26 |
-
self,
|
| 27 |
-
prompt: str,
|
| 28 |
-
*,
|
| 29 |
-
schema: dict[str, Any],
|
| 30 |
-
system_prompt: str | None = None,
|
| 31 |
-
temperature: float = 0.0,
|
| 32 |
-
) -> dict[str, Any]:
|
| 33 |
-
"""Generate structured JSON output validated against a schema description."""
|
| 34 |
-
|
| 35 |
-
@abstractmethod
|
| 36 |
-
def estimate_cost(self, input_tokens: int, output_tokens: int) -> LLMCostEstimate:
|
| 37 |
-
"""Estimate API cost for a token usage pair."""
|
| 38 |
-
|
| 39 |
-
def _parse_json_content(self, content: str) -> dict[str, Any]:
|
| 40 |
-
text = content.strip()
|
| 41 |
-
if text.startswith("```"):
|
| 42 |
-
lines = text.splitlines()
|
| 43 |
-
if lines and lines[0].startswith("```"):
|
| 44 |
-
lines = lines[1:]
|
| 45 |
-
if lines and lines[-1].strip() == "```":
|
| 46 |
-
lines = lines[:-1]
|
| 47 |
-
text = "\n".join(lines).strip()
|
| 48 |
-
return json.loads(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/llm/providers/__init__.py
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
from backend.llm.base import LLMProvider
|
| 2 |
-
from backend.llm.providers.openai_provider import OpenAIProvider
|
| 3 |
-
from backend.llm.providers.gemini_provider import GeminiProvider
|
| 4 |
-
|
| 5 |
-
SUPPORTED_PROVIDERS: dict[str, type[LLMProvider]] = {
|
| 6 |
-
"openai": OpenAIProvider,
|
| 7 |
-
"gemini": GeminiProvider,
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def get_llm_provider(provider: str, api_key: str, model: str) -> LLMProvider:
|
| 12 |
-
provider_key = provider.lower().strip()
|
| 13 |
-
if provider_key not in SUPPORTED_PROVIDERS:
|
| 14 |
-
supported = ", ".join(sorted(SUPPORTED_PROVIDERS))
|
| 15 |
-
raise ValueError(f"Unsupported LLM provider '{provider}'. Supported: {supported}")
|
| 16 |
-
return SUPPORTED_PROVIDERS[provider_key](api_key=api_key, model=model)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def list_supported_providers() -> list[str]:
|
| 20 |
-
return sorted(SUPPORTED_PROVIDERS.keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/llm/providers/gemini_provider.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
import httpx
|
| 2 |
-
|
| 3 |
-
from backend.llm.base import LLMProvider
|
| 4 |
-
from backend.llm.types import LLMCostEstimate, LLMResponse
|
| 5 |
-
|
| 6 |
-
_GEMINI_PRICING: dict[str, dict[str, float]] = {
|
| 7 |
-
"gemini-3.1-pro": {"input": 1.25, "output": 5.00},
|
| 8 |
-
"gemini-3.1-flash": {"input": 0.075, "output": 0.30},
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class GeminiProvider(LLMProvider):
|
| 13 |
-
provider_name = "gemini"
|
| 14 |
-
|
| 15 |
-
def __init__(self, api_key: str, model: str) -> None:
|
| 16 |
-
self.api_key = api_key
|
| 17 |
-
self.model = model
|
| 18 |
-
self.base_url = "https://generativelanguage.googleapis.com/v1beta"
|
| 19 |
-
|
| 20 |
-
async def generate(
|
| 21 |
-
self,
|
| 22 |
-
prompt: str,
|
| 23 |
-
*,
|
| 24 |
-
system_prompt: str | None = None,
|
| 25 |
-
temperature: float = 0.2,
|
| 26 |
-
max_tokens: int | None = None,
|
| 27 |
-
) -> LLMResponse:
|
| 28 |
-
payload: dict = {
|
| 29 |
-
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
| 30 |
-
"generationConfig": {
|
| 31 |
-
"temperature": temperature,
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
if system_prompt:
|
| 36 |
-
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
|
| 37 |
-
|
| 38 |
-
if max_tokens is not None:
|
| 39 |
-
payload["generationConfig"]["maxOutputTokens"] = max_tokens
|
| 40 |
-
|
| 41 |
-
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 42 |
-
response = await client.post(
|
| 43 |
-
f"{self.base_url}/models/{self.model}:generateContent",
|
| 44 |
-
params={"key": self.api_key},
|
| 45 |
-
headers={"Content-Type": "application/json"},
|
| 46 |
-
json=payload,
|
| 47 |
-
)
|
| 48 |
-
response.raise_for_status()
|
| 49 |
-
data = response.json()
|
| 50 |
-
|
| 51 |
-
try:
|
| 52 |
-
choice = data["candidates"][0]["content"]["parts"][0]["text"]
|
| 53 |
-
except (KeyError, IndexError):
|
| 54 |
-
choice = ""
|
| 55 |
-
|
| 56 |
-
usage = data.get("usageMetadata", {})
|
| 57 |
-
return LLMResponse(
|
| 58 |
-
content=choice,
|
| 59 |
-
input_tokens=int(usage.get("promptTokenCount", 0)),
|
| 60 |
-
output_tokens=int(usage.get("candidatesTokenCount", 0)),
|
| 61 |
-
model=self.model,
|
| 62 |
-
provider=self.provider_name,
|
| 63 |
-
raw=data,
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
async def generate_structured(
|
| 67 |
-
self,
|
| 68 |
-
prompt: str,
|
| 69 |
-
*,
|
| 70 |
-
schema: dict,
|
| 71 |
-
system_prompt: str | None = None,
|
| 72 |
-
temperature: float = 0.0,
|
| 73 |
-
) -> dict:
|
| 74 |
-
schema_instruction = (
|
| 75 |
-
"Respond with valid JSON only that matches this schema description:\n"
|
| 76 |
-
f"{schema}"
|
| 77 |
-
)
|
| 78 |
-
combined_system = (
|
| 79 |
-
f"{system_prompt}\n\n{schema_instruction}" if system_prompt else schema_instruction
|
| 80 |
-
)
|
| 81 |
-
response = await self.generate(
|
| 82 |
-
prompt,
|
| 83 |
-
system_prompt=combined_system,
|
| 84 |
-
temperature=temperature,
|
| 85 |
-
)
|
| 86 |
-
return self._parse_json_content(response.content)
|
| 87 |
-
|
| 88 |
-
def estimate_cost(self, input_tokens: int, output_tokens: int) -> LLMCostEstimate:
|
| 89 |
-
pricing = _GEMINI_PRICING.get(self.model, {"input": 1.0, "output": 3.0})
|
| 90 |
-
input_cost = (input_tokens / 1_000_000) * pricing["input"]
|
| 91 |
-
output_cost = (output_tokens / 1_000_000) * pricing["output"]
|
| 92 |
-
return LLMCostEstimate(
|
| 93 |
-
input_tokens=input_tokens,
|
| 94 |
-
output_tokens=output_tokens,
|
| 95 |
-
estimated_cost_usd=round(input_cost + output_cost, 6),
|
| 96 |
-
provider=self.provider_name,
|
| 97 |
-
model=self.model,
|
| 98 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/llm/providers/mock_provider.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
-
|
| 3 |
-
from backend.llm.base import LLMProvider
|
| 4 |
-
from backend.llm.types import LLMCostEstimate, LLMResponse
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class MockLLMProvider(LLMProvider):
|
| 8 |
-
"""Test double for LLM provider interactions."""
|
| 9 |
-
|
| 10 |
-
provider_name = "mock"
|
| 11 |
-
|
| 12 |
-
def __init__(
|
| 13 |
-
self,
|
| 14 |
-
*,
|
| 15 |
-
structured_response: dict[str, Any] | None = None,
|
| 16 |
-
text_response: str = "mock response",
|
| 17 |
-
model: str = "mock-model",
|
| 18 |
-
) -> None:
|
| 19 |
-
self.structured_response = structured_response or {"tasks": []}
|
| 20 |
-
self.text_response = text_response
|
| 21 |
-
self.model = model
|
| 22 |
-
|
| 23 |
-
async def generate(
|
| 24 |
-
self,
|
| 25 |
-
prompt: str,
|
| 26 |
-
*,
|
| 27 |
-
system_prompt: str | None = None,
|
| 28 |
-
temperature: float = 0.2,
|
| 29 |
-
max_tokens: int | None = None,
|
| 30 |
-
) -> LLMResponse:
|
| 31 |
-
return LLMResponse(
|
| 32 |
-
content=self.text_response,
|
| 33 |
-
input_tokens=10,
|
| 34 |
-
output_tokens=20,
|
| 35 |
-
model=self.model,
|
| 36 |
-
provider=self.provider_name,
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
async def generate_structured(
|
| 40 |
-
self,
|
| 41 |
-
prompt: str,
|
| 42 |
-
*,
|
| 43 |
-
schema: dict[str, Any],
|
| 44 |
-
system_prompt: str | None = None,
|
| 45 |
-
temperature: float = 0.0,
|
| 46 |
-
) -> dict[str, Any]:
|
| 47 |
-
return self.structured_response
|
| 48 |
-
|
| 49 |
-
def estimate_cost(self, input_tokens: int, output_tokens: int) -> LLMCostEstimate:
|
| 50 |
-
return LLMCostEstimate(
|
| 51 |
-
input_tokens=input_tokens,
|
| 52 |
-
output_tokens=output_tokens,
|
| 53 |
-
estimated_cost_usd=0.0,
|
| 54 |
-
provider=self.provider_name,
|
| 55 |
-
model=self.model,
|
| 56 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/llm/providers/openai_provider.py
DELETED
|
@@ -1,99 +0,0 @@
|
|
| 1 |
-
import httpx
|
| 2 |
-
|
| 3 |
-
from backend.llm.base import LLMProvider
|
| 4 |
-
from backend.llm.types import LLMCostEstimate, LLMResponse
|
| 5 |
-
|
| 6 |
-
# Approximate OpenAI pricing per 1M tokens (USD) — used for estimates only.
|
| 7 |
-
_OPENAI_PRICING: dict[str, dict[str, float]] = {
|
| 8 |
-
"gpt-4o": {"input": 2.50, "output": 10.00},
|
| 9 |
-
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
| 10 |
-
"gpt-4.1": {"input": 2.00, "output": 8.00},
|
| 11 |
-
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class OpenAIProvider(LLMProvider):
|
| 16 |
-
provider_name = "openai"
|
| 17 |
-
|
| 18 |
-
def __init__(self, api_key: str, model: str, base_url: str = "https://api.openai.com/v1") -> None:
|
| 19 |
-
self.api_key = api_key
|
| 20 |
-
self.model = model
|
| 21 |
-
self.base_url = base_url.rstrip("/")
|
| 22 |
-
|
| 23 |
-
async def generate(
|
| 24 |
-
self,
|
| 25 |
-
prompt: str,
|
| 26 |
-
*,
|
| 27 |
-
system_prompt: str | None = None,
|
| 28 |
-
temperature: float = 0.2,
|
| 29 |
-
max_tokens: int | None = None,
|
| 30 |
-
) -> LLMResponse:
|
| 31 |
-
messages: list[dict[str, str]] = []
|
| 32 |
-
if system_prompt:
|
| 33 |
-
messages.append({"role": "system", "content": system_prompt})
|
| 34 |
-
messages.append({"role": "user", "content": prompt})
|
| 35 |
-
|
| 36 |
-
payload: dict = {
|
| 37 |
-
"model": self.model,
|
| 38 |
-
"messages": messages,
|
| 39 |
-
"temperature": temperature,
|
| 40 |
-
}
|
| 41 |
-
if max_tokens is not None:
|
| 42 |
-
payload["max_tokens"] = max_tokens
|
| 43 |
-
|
| 44 |
-
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 45 |
-
response = await client.post(
|
| 46 |
-
f"{self.base_url}/chat/completions",
|
| 47 |
-
headers={
|
| 48 |
-
"Authorization": f"Bearer {self.api_key}",
|
| 49 |
-
"Content-Type": "application/json",
|
| 50 |
-
},
|
| 51 |
-
json=payload,
|
| 52 |
-
)
|
| 53 |
-
response.raise_for_status()
|
| 54 |
-
data = response.json()
|
| 55 |
-
|
| 56 |
-
choice = data["choices"][0]["message"]["content"]
|
| 57 |
-
usage = data.get("usage", {})
|
| 58 |
-
return LLMResponse(
|
| 59 |
-
content=choice,
|
| 60 |
-
input_tokens=int(usage.get("prompt_tokens", 0)),
|
| 61 |
-
output_tokens=int(usage.get("completion_tokens", 0)),
|
| 62 |
-
model=self.model,
|
| 63 |
-
provider=self.provider_name,
|
| 64 |
-
raw=data,
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
async def generate_structured(
|
| 68 |
-
self,
|
| 69 |
-
prompt: str,
|
| 70 |
-
*,
|
| 71 |
-
schema: dict,
|
| 72 |
-
system_prompt: str | None = None,
|
| 73 |
-
temperature: float = 0.0,
|
| 74 |
-
) -> dict:
|
| 75 |
-
schema_instruction = (
|
| 76 |
-
"Respond with valid JSON only that matches this schema description:\n"
|
| 77 |
-
f"{schema}"
|
| 78 |
-
)
|
| 79 |
-
combined_system = (
|
| 80 |
-
f"{system_prompt}\n\n{schema_instruction}" if system_prompt else schema_instruction
|
| 81 |
-
)
|
| 82 |
-
response = await self.generate(
|
| 83 |
-
prompt,
|
| 84 |
-
system_prompt=combined_system,
|
| 85 |
-
temperature=temperature,
|
| 86 |
-
)
|
| 87 |
-
return self._parse_json_content(response.content)
|
| 88 |
-
|
| 89 |
-
def estimate_cost(self, input_tokens: int, output_tokens: int) -> LLMCostEstimate:
|
| 90 |
-
pricing = _OPENAI_PRICING.get(self.model, {"input": 1.0, "output": 3.0})
|
| 91 |
-
input_cost = (input_tokens / 1_000_000) * pricing["input"]
|
| 92 |
-
output_cost = (output_tokens / 1_000_000) * pricing["output"]
|
| 93 |
-
return LLMCostEstimate(
|
| 94 |
-
input_tokens=input_tokens,
|
| 95 |
-
output_tokens=output_tokens,
|
| 96 |
-
estimated_cost_usd=round(input_cost + output_cost, 6),
|
| 97 |
-
provider=self.provider_name,
|
| 98 |
-
model=self.model,
|
| 99 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/llm/types.py
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 1 |
-
from dataclasses import dataclass
|
| 2 |
-
from typing import Any
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
@dataclass
|
| 6 |
-
class LLMResponse:
|
| 7 |
-
content: str
|
| 8 |
-
input_tokens: int
|
| 9 |
-
output_tokens: int
|
| 10 |
-
model: str
|
| 11 |
-
provider: str
|
| 12 |
-
raw: dict[str, Any] | None = None
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
@dataclass
|
| 16 |
-
class LLMCostEstimate:
|
| 17 |
-
input_tokens: int
|
| 18 |
-
output_tokens: int
|
| 19 |
-
estimated_cost_usd: float
|
| 20 |
-
provider: str
|
| 21 |
-
model: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/main.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from contextlib import asynccontextmanager
|
| 3 |
-
|
| 4 |
-
from fastapi import FastAPI
|
| 5 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
-
from sqlalchemy import text
|
| 7 |
-
|
| 8 |
-
from backend.api.auth import router as auth_router
|
| 9 |
-
from backend.api.providers import router as providers_router
|
| 10 |
-
from backend.api.settings import router as settings_router
|
| 11 |
-
from backend.api.workflows import router as workflows_router
|
| 12 |
-
from backend.api.schemas import HealthResponse
|
| 13 |
-
from backend.core.config import get_settings
|
| 14 |
-
from backend.db.models import Base
|
| 15 |
-
from backend.db.session import engine
|
| 16 |
-
|
| 17 |
-
logger = logging.getLogger(__name__)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
@asynccontextmanager
|
| 21 |
-
async def lifespan(app: FastAPI):
|
| 22 |
-
settings = get_settings()
|
| 23 |
-
logging.basicConfig(level=settings.log_level)
|
| 24 |
-
logger.info("Starting AutoForge backend")
|
| 25 |
-
|
| 26 |
-
# Automatically initialize database schema for Hugging Face Spaces
|
| 27 |
-
async with engine.begin() as conn:
|
| 28 |
-
await conn.run_sync(Base.metadata.create_all)
|
| 29 |
-
|
| 30 |
-
yield
|
| 31 |
-
await engine.dispose()
|
| 32 |
-
logger.info("AutoForge backend stopped")
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def create_app() -> FastAPI:
|
| 36 |
-
settings = get_settings()
|
| 37 |
-
app = FastAPI(
|
| 38 |
-
title="AutoForge API",
|
| 39 |
-
description="Autonomous DAG-based code generation and verification platform",
|
| 40 |
-
version="0.1.0",
|
| 41 |
-
lifespan=lifespan,
|
| 42 |
-
)
|
| 43 |
-
|
| 44 |
-
origins = [origin.strip() for origin in settings.cors_origins.split(",") if origin.strip()]
|
| 45 |
-
app.add_middleware(
|
| 46 |
-
CORSMiddleware,
|
| 47 |
-
allow_origins=origins,
|
| 48 |
-
allow_credentials=True,
|
| 49 |
-
allow_methods=["*"],
|
| 50 |
-
allow_headers=["*"],
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
from fastapi import APIRouter
|
| 54 |
-
from fastapi.staticfiles import StaticFiles
|
| 55 |
-
from fastapi.responses import FileResponse
|
| 56 |
-
import os
|
| 57 |
-
|
| 58 |
-
api_router = APIRouter(prefix="/api")
|
| 59 |
-
api_router.include_router(auth_router)
|
| 60 |
-
api_router.include_router(workflows_router)
|
| 61 |
-
api_router.include_router(settings_router)
|
| 62 |
-
api_router.include_router(providers_router)
|
| 63 |
-
|
| 64 |
-
@api_router.get("/health", response_model=HealthResponse, tags=["health"])
|
| 65 |
-
async def health_check() -> HealthResponse:
|
| 66 |
-
db_status = "disconnected"
|
| 67 |
-
try:
|
| 68 |
-
async with engine.connect() as conn:
|
| 69 |
-
await conn.execute(text("SELECT 1"))
|
| 70 |
-
db_status = "connected"
|
| 71 |
-
except Exception as exc:
|
| 72 |
-
logger.warning("Database health check failed: %s", exc)
|
| 73 |
-
|
| 74 |
-
return HealthResponse(
|
| 75 |
-
status="ok" if db_status == "connected" else "degraded",
|
| 76 |
-
database=db_status,
|
| 77 |
-
)
|
| 78 |
-
|
| 79 |
-
app.include_router(api_router)
|
| 80 |
-
|
| 81 |
-
# Serve static frontend if it exists
|
| 82 |
-
frontend_dir = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
|
| 83 |
-
if os.path.isdir(frontend_dir):
|
| 84 |
-
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets")
|
| 85 |
-
|
| 86 |
-
@app.get("/{full_path:path}")
|
| 87 |
-
async def serve_frontend(full_path: str):
|
| 88 |
-
# If a specific file is requested in root (like favicon.ico), try serving it
|
| 89 |
-
requested_file = os.path.join(frontend_dir, full_path)
|
| 90 |
-
if os.path.isfile(requested_file):
|
| 91 |
-
return FileResponse(requested_file)
|
| 92 |
-
# Otherwise, fall back to index.html for SPA routing
|
| 93 |
-
return FileResponse(os.path.join(frontend_dir, "index.html"))
|
| 94 |
-
|
| 95 |
-
return app
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
app = create_app()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/memory/__init__.py
DELETED
|
File without changes
|
backend/memory/attempt_repository.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
from sqlalchemy import func, select
|
| 2 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
-
|
| 4 |
-
from backend.db.models import Attempt
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class AttemptRepository:
|
| 8 |
-
def __init__(self, db: AsyncSession) -> None:
|
| 9 |
-
self.db = db
|
| 10 |
-
|
| 11 |
-
async def get_next_attempt_number(self, workflow_id: int) -> int:
|
| 12 |
-
result = await self.db.execute(
|
| 13 |
-
select(func.max(Attempt.attempt_number)).where(Attempt.workflow_id == workflow_id)
|
| 14 |
-
)
|
| 15 |
-
current_max = result.scalar()
|
| 16 |
-
return int(current_max or 0) + 1
|
| 17 |
-
|
| 18 |
-
async def list_attempts_for_workflow(self, workflow_id: int) -> list[Attempt]:
|
| 19 |
-
result = await self.db.execute(
|
| 20 |
-
select(Attempt)
|
| 21 |
-
.where(Attempt.workflow_id == workflow_id)
|
| 22 |
-
.order_by(Attempt.attempt_number.asc(), Attempt.id.asc())
|
| 23 |
-
)
|
| 24 |
-
return list(result.scalars().all())
|
| 25 |
-
|
| 26 |
-
async def count_attempts(self, workflow_id: int) -> int:
|
| 27 |
-
result = await self.db.execute(
|
| 28 |
-
select(func.count()).select_from(Attempt).where(Attempt.workflow_id == workflow_id)
|
| 29 |
-
)
|
| 30 |
-
return int(result.scalar() or 0)
|
| 31 |
-
|
| 32 |
-
async def create_attempt(
|
| 33 |
-
self,
|
| 34 |
-
*,
|
| 35 |
-
workflow_id: int,
|
| 36 |
-
task_id: int,
|
| 37 |
-
attempt_number: int = 1,
|
| 38 |
-
generated_code: dict | None = None,
|
| 39 |
-
generated_tests: dict | None = None,
|
| 40 |
-
static_analysis_result: dict | None = None,
|
| 41 |
-
stdout: str | None = None,
|
| 42 |
-
stderr: str | None = None,
|
| 43 |
-
exit_code: int | None = None,
|
| 44 |
-
result: str | None = None,
|
| 45 |
-
error: dict | None = None,
|
| 46 |
-
) -> Attempt:
|
| 47 |
-
attempt = Attempt(
|
| 48 |
-
workflow_id=workflow_id,
|
| 49 |
-
task_id=task_id,
|
| 50 |
-
attempt_number=attempt_number,
|
| 51 |
-
generated_code=generated_code,
|
| 52 |
-
generated_tests=generated_tests,
|
| 53 |
-
static_analysis_result=static_analysis_result,
|
| 54 |
-
stdout=stdout,
|
| 55 |
-
stderr=stderr,
|
| 56 |
-
exit_code=exit_code,
|
| 57 |
-
result=result,
|
| 58 |
-
error=error,
|
| 59 |
-
)
|
| 60 |
-
self.db.add(attempt)
|
| 61 |
-
await self.db.flush()
|
| 62 |
-
await self.db.refresh(attempt)
|
| 63 |
-
return attempt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/memory/task_repository.py
DELETED
|
@@ -1,99 +0,0 @@
|
|
| 1 |
-
from datetime import UTC, datetime
|
| 2 |
-
|
| 3 |
-
from sqlalchemy import select
|
| 4 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
-
|
| 6 |
-
from backend.db.models import Task, TaskStatus, Workflow, WorkflowStatus
|
| 7 |
-
from backend.orchestration.dag_schema import DAGPlan
|
| 8 |
-
from backend.orchestration.state_machine import validate_transition
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class TaskRepository:
|
| 12 |
-
def __init__(self, db: AsyncSession) -> None:
|
| 13 |
-
self.db = db
|
| 14 |
-
|
| 15 |
-
async def create_tasks_from_plan(
|
| 16 |
-
self, workflow: Workflow, plan: DAGPlan
|
| 17 |
-
) -> list[Task]:
|
| 18 |
-
tasks: list[Task] = []
|
| 19 |
-
for task_def in plan.tasks:
|
| 20 |
-
task = Task(
|
| 21 |
-
workflow_id=workflow.id,
|
| 22 |
-
task_key=task_def.id,
|
| 23 |
-
task_type=task_def.type.value,
|
| 24 |
-
status=TaskStatus.CREATED,
|
| 25 |
-
depends_on=task_def.depends_on,
|
| 26 |
-
)
|
| 27 |
-
self.db.add(task)
|
| 28 |
-
tasks.append(task)
|
| 29 |
-
await self.db.flush()
|
| 30 |
-
for task in tasks:
|
| 31 |
-
await self.db.refresh(task)
|
| 32 |
-
return tasks
|
| 33 |
-
|
| 34 |
-
async def get_tasks_for_workflow(self, workflow_db_id: int) -> list[Task]:
|
| 35 |
-
result = await self.db.execute(
|
| 36 |
-
select(Task)
|
| 37 |
-
.where(Task.workflow_id == workflow_db_id)
|
| 38 |
-
.order_by(Task.id.asc())
|
| 39 |
-
)
|
| 40 |
-
return list(result.scalars().all())
|
| 41 |
-
|
| 42 |
-
async def get_task_by_key(self, workflow_db_id: int, task_key: str) -> Task | None:
|
| 43 |
-
result = await self.db.execute(
|
| 44 |
-
select(Task).where(
|
| 45 |
-
Task.workflow_id == workflow_db_id,
|
| 46 |
-
Task.task_key == task_key,
|
| 47 |
-
)
|
| 48 |
-
)
|
| 49 |
-
return result.scalar_one_or_none()
|
| 50 |
-
|
| 51 |
-
async def get_task_by_type(self, workflow_db_id: int, task_type: str) -> Task | None:
|
| 52 |
-
result = await self.db.execute(
|
| 53 |
-
select(Task).where(
|
| 54 |
-
Task.workflow_id == workflow_db_id,
|
| 55 |
-
Task.task_type == task_type,
|
| 56 |
-
)
|
| 57 |
-
)
|
| 58 |
-
return result.scalar_one_or_none()
|
| 59 |
-
|
| 60 |
-
async def transition_task(
|
| 61 |
-
self,
|
| 62 |
-
task: Task,
|
| 63 |
-
target_status: TaskStatus | str,
|
| 64 |
-
*,
|
| 65 |
-
output: dict | None = None,
|
| 66 |
-
set_started: bool = False,
|
| 67 |
-
set_completed: bool = False,
|
| 68 |
-
) -> Task:
|
| 69 |
-
validate_transition(task.status, target_status)
|
| 70 |
-
task.status = str(target_status)
|
| 71 |
-
if output is not None:
|
| 72 |
-
task.output = output
|
| 73 |
-
if set_started and task.started_at is None:
|
| 74 |
-
task.started_at = datetime.now(UTC)
|
| 75 |
-
if set_completed:
|
| 76 |
-
task.completed_at = datetime.now(UTC)
|
| 77 |
-
await self.db.flush()
|
| 78 |
-
await self.db.refresh(task)
|
| 79 |
-
return task
|
| 80 |
-
|
| 81 |
-
async def mark_workflow_status(
|
| 82 |
-
self,
|
| 83 |
-
workflow: Workflow,
|
| 84 |
-
status: WorkflowStatus | str,
|
| 85 |
-
*,
|
| 86 |
-
completed: bool = False,
|
| 87 |
-
) -> Workflow:
|
| 88 |
-
workflow.status = str(status)
|
| 89 |
-
if completed:
|
| 90 |
-
workflow.completed_at = datetime.now(UTC)
|
| 91 |
-
await self.db.flush()
|
| 92 |
-
await self.db.refresh(workflow)
|
| 93 |
-
return workflow
|
| 94 |
-
|
| 95 |
-
async def save_planner_output(self, workflow: Workflow, planner_output: dict) -> Workflow:
|
| 96 |
-
workflow.planner_output = planner_output
|
| 97 |
-
await self.db.flush()
|
| 98 |
-
await self.db.refresh(workflow)
|
| 99 |
-
return workflow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/memory/workflow_repository.py
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 1 |
-
import uuid
|
| 2 |
-
from datetime import datetime
|
| 3 |
-
|
| 4 |
-
from sqlalchemy import select
|
| 5 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
-
from sqlalchemy.orm import selectinload
|
| 7 |
-
|
| 8 |
-
from backend.db.models import Event, User, Workflow, WorkflowStatus
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def generate_workflow_id() -> str:
|
| 12 |
-
return f"wf_{uuid.uuid4().hex[:12]}"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class WorkflowRepository:
|
| 16 |
-
def __init__(self, db: AsyncSession) -> None:
|
| 17 |
-
self.db = db
|
| 18 |
-
|
| 19 |
-
async def create_workflow(
|
| 20 |
-
self,
|
| 21 |
-
*,
|
| 22 |
-
user_id: int,
|
| 23 |
-
user_prompt: str,
|
| 24 |
-
llm_provider: str | None = None,
|
| 25 |
-
llm_model: str | None = None,
|
| 26 |
-
) -> Workflow:
|
| 27 |
-
workflow = Workflow(
|
| 28 |
-
workflow_id=generate_workflow_id(),
|
| 29 |
-
user_id=user_id,
|
| 30 |
-
user_prompt=user_prompt,
|
| 31 |
-
status=WorkflowStatus.CREATED,
|
| 32 |
-
llm_provider=llm_provider,
|
| 33 |
-
llm_model=llm_model,
|
| 34 |
-
)
|
| 35 |
-
self.db.add(workflow)
|
| 36 |
-
await self.db.flush()
|
| 37 |
-
|
| 38 |
-
event = Event(
|
| 39 |
-
workflow_id=workflow.id,
|
| 40 |
-
event_type="WORKFLOW_STARTED",
|
| 41 |
-
message="Workflow created",
|
| 42 |
-
event_metadata={"workflow_id": workflow.workflow_id},
|
| 43 |
-
)
|
| 44 |
-
self.db.add(event)
|
| 45 |
-
await self.db.flush()
|
| 46 |
-
await self.db.refresh(workflow)
|
| 47 |
-
return workflow
|
| 48 |
-
|
| 49 |
-
async def get_workflow_by_public_id(
|
| 50 |
-
self, workflow_id: str, user_id: int
|
| 51 |
-
) -> Workflow | None:
|
| 52 |
-
result = await self.db.execute(
|
| 53 |
-
select(Workflow)
|
| 54 |
-
.where(Workflow.workflow_id == workflow_id, Workflow.user_id == user_id)
|
| 55 |
-
.options(selectinload(Workflow.events))
|
| 56 |
-
)
|
| 57 |
-
return result.scalar_one_or_none()
|
| 58 |
-
|
| 59 |
-
async def list_workflows_for_user(self, user_id: int) -> list[Workflow]:
|
| 60 |
-
result = await self.db.execute(
|
| 61 |
-
select(Workflow)
|
| 62 |
-
.where(Workflow.user_id == user_id)
|
| 63 |
-
.order_by(Workflow.created_at.desc())
|
| 64 |
-
)
|
| 65 |
-
return list(result.scalars().all())
|
| 66 |
-
|
| 67 |
-
async def get_user_by_email(self, email: str) -> User | None:
|
| 68 |
-
result = await self.db.execute(select(User).where(User.email == email))
|
| 69 |
-
return result.scalar_one_or_none()
|
| 70 |
-
|
| 71 |
-
async def get_user_by_username(self, username: str) -> User | None:
|
| 72 |
-
result = await self.db.execute(select(User).where(User.username == username))
|
| 73 |
-
return result.scalar_one_or_none()
|
| 74 |
-
|
| 75 |
-
async def get_user_by_id(self, user_id: int) -> User | None:
|
| 76 |
-
result = await self.db.execute(select(User).where(User.id == user_id))
|
| 77 |
-
return result.scalar_one_or_none()
|
| 78 |
-
|
| 79 |
-
async def create_user(self, *, email: str, username: str, password_hash: str) -> User:
|
| 80 |
-
user = User(email=email, username=username, password_hash=password_hash)
|
| 81 |
-
self.db.add(user)
|
| 82 |
-
await self.db.flush()
|
| 83 |
-
await self.db.refresh(user)
|
| 84 |
-
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/orchestration/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""DAG engine — validation, scheduling, execution, and workflow orchestration."""
|
|
|
|
|
|
backend/orchestration/dag_engine.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
from dataclasses import dataclass, field
|
| 3 |
-
from datetime import UTC, datetime
|
| 4 |
-
from typing import Any
|
| 5 |
-
|
| 6 |
-
from backend.agents.task_handlers import get_task_handler
|
| 7 |
-
from backend.orchestration.dag_schema import DAGPlan, DAGTaskDefinition, TaskType
|
| 8 |
-
from backend.orchestration.errors import TaskExecutionError
|
| 9 |
-
from backend.orchestration.scheduler import get_ready_task_ids
|
| 10 |
-
from backend.orchestration.task_context import TaskContext
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
@dataclass
|
| 14 |
-
class TaskRunRecord:
|
| 15 |
-
task_key: str
|
| 16 |
-
task_type: TaskType
|
| 17 |
-
status: str
|
| 18 |
-
output: dict[str, Any] | None = None
|
| 19 |
-
error: dict[str, Any] | None = None
|
| 20 |
-
started_at: datetime | None = None
|
| 21 |
-
completed_at: datetime | None = None
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
@dataclass
|
| 25 |
-
class DAGExecutionResult:
|
| 26 |
-
success: bool
|
| 27 |
-
task_results: dict[str, TaskRunRecord] = field(default_factory=dict)
|
| 28 |
-
error: str | None = None
|
| 29 |
-
task_outputs_by_type: dict[str, dict[str, Any]] = field(default_factory=dict)
|
| 30 |
-
failed_task_type: str | None = None
|
| 31 |
-
failure_error: dict[str, Any] | None = None
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class DAGExecutor:
|
| 35 |
-
"""Executes a validated DAG with parallel execution of independent tasks."""
|
| 36 |
-
|
| 37 |
-
def __init__(
|
| 38 |
-
self,
|
| 39 |
-
*,
|
| 40 |
-
base_context: dict[str, Any] | None = None,
|
| 41 |
-
on_task_started: Any | None = None,
|
| 42 |
-
on_task_completed: Any | None = None,
|
| 43 |
-
on_task_failed: Any | None = None,
|
| 44 |
-
) -> None:
|
| 45 |
-
self.base_context = base_context or {}
|
| 46 |
-
self.on_task_started = on_task_started
|
| 47 |
-
self.on_task_completed = on_task_completed
|
| 48 |
-
self.on_task_failed = on_task_failed
|
| 49 |
-
self.task_outputs: dict[str, dict[str, Any]] = {}
|
| 50 |
-
self.task_outputs_by_type: dict[str, dict[str, Any]] = {}
|
| 51 |
-
|
| 52 |
-
async def execute(self, plan: DAGPlan) -> DAGExecutionResult:
|
| 53 |
-
completed: set[str] = set()
|
| 54 |
-
results: dict[str, TaskRunRecord] = {
|
| 55 |
-
task.id: TaskRunRecord(task_key=task.id, task_type=task.type, status="CREATED")
|
| 56 |
-
for task in plan.tasks
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
while len(completed) < len(plan.tasks):
|
| 60 |
-
ready_ids = get_ready_task_ids(plan, completed)
|
| 61 |
-
if not ready_ids:
|
| 62 |
-
return DAGExecutionResult(
|
| 63 |
-
success=False,
|
| 64 |
-
task_results=results,
|
| 65 |
-
error="Deadlock detected: no ready tasks but DAG is incomplete",
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
layer_outcome = await self._execute_layer(plan, ready_ids, results)
|
| 69 |
-
if not layer_outcome.success:
|
| 70 |
-
return layer_outcome
|
| 71 |
-
|
| 72 |
-
completed.update(ready_ids)
|
| 73 |
-
|
| 74 |
-
return DAGExecutionResult(
|
| 75 |
-
success=True,
|
| 76 |
-
task_results=results,
|
| 77 |
-
task_outputs_by_type=dict(self.task_outputs_by_type),
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
-
async def _execute_layer(
|
| 81 |
-
self,
|
| 82 |
-
plan: DAGPlan,
|
| 83 |
-
ready_ids: list[str],
|
| 84 |
-
results: dict[str, TaskRunRecord],
|
| 85 |
-
) -> DAGExecutionResult:
|
| 86 |
-
outcomes = await asyncio.gather(
|
| 87 |
-
*[self._run_task(plan, task_id, results) for task_id in ready_ids],
|
| 88 |
-
return_exceptions=True,
|
| 89 |
-
)
|
| 90 |
-
|
| 91 |
-
for outcome in outcomes:
|
| 92 |
-
if isinstance(outcome, DAGExecutionResult):
|
| 93 |
-
return outcome
|
| 94 |
-
if isinstance(outcome, Exception):
|
| 95 |
-
return DAGExecutionResult(
|
| 96 |
-
success=False,
|
| 97 |
-
task_results=results,
|
| 98 |
-
error=str(outcome),
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
return DAGExecutionResult(success=True, task_results=results)
|
| 102 |
-
|
| 103 |
-
def _build_context(self, task_def: DAGTaskDefinition) -> TaskContext:
|
| 104 |
-
return TaskContext(
|
| 105 |
-
workflow_id=self.base_context["workflow_id"],
|
| 106 |
-
user_prompt=self.base_context["user_prompt"],
|
| 107 |
-
llm=self.base_context["llm"],
|
| 108 |
-
workspace_path=self.base_context["workspace_path"],
|
| 109 |
-
task_def=task_def,
|
| 110 |
-
task_outputs=dict(self.task_outputs),
|
| 111 |
-
task_outputs_by_type=dict(self.task_outputs_by_type),
|
| 112 |
-
docker_runner=self.base_context.get("docker_runner"),
|
| 113 |
-
use_stubs=self.base_context.get("use_stubs", False),
|
| 114 |
-
fail_task=self.base_context.get("fail_task"),
|
| 115 |
-
stub_delay=self.base_context.get("stub_delay", 0),
|
| 116 |
-
stub_execute_calls_docker=self.base_context.get("stub_execute_calls_docker", False),
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
async def _run_task(
|
| 120 |
-
self,
|
| 121 |
-
plan: DAGPlan,
|
| 122 |
-
task_id: str,
|
| 123 |
-
results: dict[str, TaskRunRecord],
|
| 124 |
-
) -> None | DAGExecutionResult:
|
| 125 |
-
task_def = plan.task_map()[task_id]
|
| 126 |
-
record = results[task_id]
|
| 127 |
-
record.status = "RUNNING"
|
| 128 |
-
record.started_at = datetime.now(UTC)
|
| 129 |
-
|
| 130 |
-
if self.on_task_started:
|
| 131 |
-
await self.on_task_started(task_def)
|
| 132 |
-
|
| 133 |
-
context = self._build_context(task_def)
|
| 134 |
-
|
| 135 |
-
try:
|
| 136 |
-
handler = get_task_handler(task_def.type)
|
| 137 |
-
output = await handler(context)
|
| 138 |
-
record.output = output
|
| 139 |
-
record.status = "SUCCESS"
|
| 140 |
-
record.completed_at = datetime.now(UTC)
|
| 141 |
-
self.task_outputs[task_id] = output
|
| 142 |
-
self.task_outputs_by_type[task_def.type.value] = output
|
| 143 |
-
if self.on_task_completed:
|
| 144 |
-
await self.on_task_completed(task_def, output)
|
| 145 |
-
except TaskExecutionError as exc:
|
| 146 |
-
record.status = "FAILED"
|
| 147 |
-
record.completed_at = datetime.now(UTC)
|
| 148 |
-
record.error = {"error_type": exc.error_type, "message": exc.message}
|
| 149 |
-
if self.on_task_failed:
|
| 150 |
-
await self.on_task_failed(task_def, record.error)
|
| 151 |
-
return DAGExecutionResult(
|
| 152 |
-
success=False,
|
| 153 |
-
task_results=results,
|
| 154 |
-
error=exc.message,
|
| 155 |
-
task_outputs_by_type=dict(self.task_outputs_by_type),
|
| 156 |
-
failed_task_type=task_def.type.value,
|
| 157 |
-
failure_error=record.error,
|
| 158 |
-
)
|
| 159 |
-
except Exception as exc:
|
| 160 |
-
record.status = "FAILED"
|
| 161 |
-
record.completed_at = datetime.now(UTC)
|
| 162 |
-
record.error = {"error_type": "TASK_EXECUTION_ERROR", "message": str(exc)}
|
| 163 |
-
if self.on_task_failed:
|
| 164 |
-
await self.on_task_failed(task_def, record.error)
|
| 165 |
-
return DAGExecutionResult(
|
| 166 |
-
success=False,
|
| 167 |
-
task_results=results,
|
| 168 |
-
error=str(exc),
|
| 169 |
-
task_outputs_by_type=dict(self.task_outputs_by_type),
|
| 170 |
-
failed_task_type=task_def.type.value,
|
| 171 |
-
failure_error=record.error,
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/orchestration/dag_schema.py
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
from enum import StrEnum
|
| 2 |
-
|
| 3 |
-
from pydantic import BaseModel, Field, field_validator
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class TaskType(StrEnum):
|
| 7 |
-
CODE_GENERATION = "code_generation"
|
| 8 |
-
TEST_GENERATION = "test_generation"
|
| 9 |
-
STATIC_ANALYSIS = "static_analysis"
|
| 10 |
-
EXECUTE = "execute"
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
VALID_TASK_TYPES = frozenset(TaskType)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class DAGTaskDefinition(BaseModel):
|
| 17 |
-
id: str = Field(min_length=1, max_length=64)
|
| 18 |
-
type: TaskType
|
| 19 |
-
depends_on: list[str] = Field(default_factory=list)
|
| 20 |
-
|
| 21 |
-
@field_validator("id")
|
| 22 |
-
@classmethod
|
| 23 |
-
def normalize_id(cls, value: str) -> str:
|
| 24 |
-
normalized = value.strip()
|
| 25 |
-
if not normalized:
|
| 26 |
-
raise ValueError("Task id cannot be empty")
|
| 27 |
-
return normalized
|
| 28 |
-
|
| 29 |
-
@field_validator("depends_on")
|
| 30 |
-
@classmethod
|
| 31 |
-
def normalize_dependencies(cls, value: list[str]) -> list[str]:
|
| 32 |
-
return [dep.strip() for dep in value if dep.strip()]
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class DAGPlan(BaseModel):
|
| 36 |
-
tasks: list[DAGTaskDefinition] = Field(min_length=1)
|
| 37 |
-
|
| 38 |
-
def task_ids(self) -> set[str]:
|
| 39 |
-
return {task.id for task in self.tasks}
|
| 40 |
-
|
| 41 |
-
def task_map(self) -> dict[str, DAGTaskDefinition]:
|
| 42 |
-
return {task.id: task for task in self.tasks}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/orchestration/dag_validator.py
DELETED
|
@@ -1,85 +0,0 @@
|
|
| 1 |
-
class DAGValidationError(Exception):
|
| 2 |
-
"""Raised when a planner-produced DAG fails validation."""
|
| 3 |
-
|
| 4 |
-
def __init__(self, errors: list[str]) -> None:
|
| 5 |
-
self.errors = errors
|
| 6 |
-
message = "; ".join(errors)
|
| 7 |
-
super().__init__(message)
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
def validate_dag(plan: "DAGPlan") -> None:
|
| 11 |
-
"""Validate DAG structure, references, and acyclicity."""
|
| 12 |
-
from backend.orchestration.dag_schema import DAGPlan as DAGPlanModel
|
| 13 |
-
|
| 14 |
-
if not isinstance(plan, DAGPlanModel):
|
| 15 |
-
raise TypeError("plan must be a DAGPlan instance")
|
| 16 |
-
|
| 17 |
-
errors: list[str] = []
|
| 18 |
-
task_ids = plan.task_ids()
|
| 19 |
-
|
| 20 |
-
if len(task_ids) != len(plan.tasks):
|
| 21 |
-
errors.append("Duplicate task ids are not allowed")
|
| 22 |
-
|
| 23 |
-
for task in plan.tasks:
|
| 24 |
-
for dependency in task.depends_on:
|
| 25 |
-
if dependency == task.id:
|
| 26 |
-
errors.append(f"Task '{task.id}' cannot depend on itself")
|
| 27 |
-
elif dependency not in task_ids:
|
| 28 |
-
errors.append(
|
| 29 |
-
f"Task '{task.id}' depends on unknown task '{dependency}'"
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
if errors:
|
| 33 |
-
raise DAGValidationError(errors)
|
| 34 |
-
|
| 35 |
-
if _has_cycle(plan):
|
| 36 |
-
raise DAGValidationError(["DAG contains a cycle"])
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def _has_cycle(plan: "DAGPlan") -> bool:
|
| 40 |
-
graph = {task.id: list(task.depends_on) for task in plan.tasks}
|
| 41 |
-
visited: set[str] = set()
|
| 42 |
-
stack: set[str] = set()
|
| 43 |
-
|
| 44 |
-
def visit(node: str) -> bool:
|
| 45 |
-
if node in stack:
|
| 46 |
-
return True
|
| 47 |
-
if node in visited:
|
| 48 |
-
return False
|
| 49 |
-
visited.add(node)
|
| 50 |
-
stack.add(node)
|
| 51 |
-
for dependency in graph[node]:
|
| 52 |
-
if visit(dependency):
|
| 53 |
-
return True
|
| 54 |
-
stack.remove(node)
|
| 55 |
-
return False
|
| 56 |
-
|
| 57 |
-
return any(visit(task_id) for task_id in graph)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def topological_order(plan: "DAGPlan") -> list[str]:
|
| 61 |
-
"""Return a valid topological ordering of task ids."""
|
| 62 |
-
validate_dag(plan)
|
| 63 |
-
|
| 64 |
-
in_degree = {task.id: 0 for task in plan.tasks}
|
| 65 |
-
dependents: dict[str, list[str]] = {task.id: [] for task in plan.tasks}
|
| 66 |
-
|
| 67 |
-
for task in plan.tasks:
|
| 68 |
-
for dependency in task.depends_on:
|
| 69 |
-
in_degree[task.id] += 1
|
| 70 |
-
dependents[dependency].append(task.id)
|
| 71 |
-
|
| 72 |
-
ready = [task_id for task_id, degree in in_degree.items() if degree == 0]
|
| 73 |
-
order: list[str] = []
|
| 74 |
-
|
| 75 |
-
while ready:
|
| 76 |
-
current = ready.pop(0)
|
| 77 |
-
order.append(current)
|
| 78 |
-
for dependent in dependents[current]:
|
| 79 |
-
in_degree[dependent] -= 1
|
| 80 |
-
if in_degree[dependent] == 0:
|
| 81 |
-
ready.append(dependent)
|
| 82 |
-
|
| 83 |
-
if len(order) != len(plan.tasks):
|
| 84 |
-
raise DAGValidationError(["DAG contains a cycle"])
|
| 85 |
-
return order
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|