execforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. execforge-0.1.0.dist-info/METADATA +367 -0
  2. execforge-0.1.0.dist-info/RECORD +44 -0
  3. execforge-0.1.0.dist-info/WHEEL +5 -0
  4. execforge-0.1.0.dist-info/entry_points.txt +5 -0
  5. execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. execforge-0.1.0.dist-info/top_level.txt +1 -0
  7. orchestrator/__init__.py +4 -0
  8. orchestrator/__main__.py +5 -0
  9. orchestrator/backends/__init__.py +1 -0
  10. orchestrator/backends/base.py +29 -0
  11. orchestrator/backends/factory.py +53 -0
  12. orchestrator/backends/llm_cli_backend.py +87 -0
  13. orchestrator/backends/mock_backend.py +34 -0
  14. orchestrator/backends/shell_backend.py +49 -0
  15. orchestrator/cli/__init__.py +1 -0
  16. orchestrator/cli/main.py +971 -0
  17. orchestrator/config.py +272 -0
  18. orchestrator/domain/__init__.py +1 -0
  19. orchestrator/domain/types.py +77 -0
  20. orchestrator/exceptions.py +18 -0
  21. orchestrator/git/__init__.py +1 -0
  22. orchestrator/git/service.py +202 -0
  23. orchestrator/logging_setup.py +53 -0
  24. orchestrator/prompts/__init__.py +1 -0
  25. orchestrator/prompts/parser.py +91 -0
  26. orchestrator/reporting/__init__.py +1 -0
  27. orchestrator/reporting/console.py +197 -0
  28. orchestrator/reporting/events.py +44 -0
  29. orchestrator/reporting/selection_result.py +15 -0
  30. orchestrator/services/__init__.py +1 -0
  31. orchestrator/services/agent_runner.py +831 -0
  32. orchestrator/services/agent_service.py +122 -0
  33. orchestrator/services/project_service.py +47 -0
  34. orchestrator/services/prompt_source_service.py +65 -0
  35. orchestrator/services/run_service.py +42 -0
  36. orchestrator/services/step_executor.py +100 -0
  37. orchestrator/services/task_service.py +155 -0
  38. orchestrator/storage/__init__.py +1 -0
  39. orchestrator/storage/db.py +29 -0
  40. orchestrator/storage/models.py +95 -0
  41. orchestrator/utils/__init__.py +1 -0
  42. orchestrator/utils/process.py +44 -0
  43. orchestrator/validation/__init__.py +1 -0
  44. orchestrator/validation/pipeline.py +52 -0
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from sqlalchemy import delete
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from orchestrator.exceptions import ConfigError
10
+ from orchestrator.storage.models import AgentORM, RunORM
11
+
12
+
13
+ class AgentService:
14
+ def __init__(self, session: Session):
15
+ self.session = session
16
+
17
+ def add(
18
+ self,
19
+ name: str,
20
+ prompt_source_id: int,
21
+ project_repo_id: int,
22
+ execution_backend: str = "multi",
23
+ task_selector_strategy: str = "priority_then_oldest",
24
+ validation_policy: list[dict] | None = None,
25
+ model_settings: dict | None = None,
26
+ safety_settings: dict | None = None,
27
+ push_policy: str = "never",
28
+ autonomy_level: str = "semi-auto",
29
+ max_steps: int = 20,
30
+ ) -> AgentORM:
31
+ item = AgentORM(
32
+ name=name,
33
+ prompt_source_id=prompt_source_id,
34
+ project_repo_id=project_repo_id,
35
+ execution_backend=execution_backend,
36
+ task_selector_strategy=task_selector_strategy,
37
+ validation_policy_json=json.dumps(validation_policy or []),
38
+ model_settings_json=json.dumps(model_settings or {}),
39
+ safety_settings_json=json.dumps(safety_settings or {}),
40
+ commit_policy_json=json.dumps({"message_template": "feat(agent): complete {task_ref} {title}"}),
41
+ push_policy=push_policy,
42
+ autonomy_level=autonomy_level,
43
+ max_steps=max_steps,
44
+ active=True,
45
+ )
46
+ self.session.add(item)
47
+ self.session.flush()
48
+ return item
49
+
50
+ def list(self) -> list[AgentORM]:
51
+ return list(self.session.scalars(select(AgentORM).order_by(AgentORM.id)).all())
52
+
53
+ def get(self, agent_id_or_name: str) -> AgentORM | None:
54
+ stmt = select(AgentORM).where(AgentORM.name == agent_id_or_name)
55
+ item = self.session.scalar(stmt)
56
+ if item:
57
+ return item
58
+ if agent_id_or_name.isdigit():
59
+ return self.session.get(AgentORM, int(agent_id_or_name))
60
+ return None
61
+
62
+ def update(self, agent: AgentORM, updates: dict[str, str]) -> AgentORM:
63
+ def parse_jsonish(value: str):
64
+ lowered = value.strip().lower()
65
+ if lowered == "true":
66
+ return True
67
+ if lowered == "false":
68
+ return False
69
+ if lowered == "null":
70
+ return None
71
+ try:
72
+ if value.strip().isdigit() or (value.strip().startswith("-") and value.strip()[1:].isdigit()):
73
+ return int(value)
74
+ except Exception:
75
+ pass
76
+ return value
77
+
78
+ for key, value in updates.items():
79
+ if key in {"name", "execution_backend", "task_selector_strategy", "push_policy", "autonomy_level"}:
80
+ setattr(agent, key, str(value))
81
+ continue
82
+ if key in {"max_steps"}:
83
+ try:
84
+ setattr(agent, key, int(value))
85
+ except ValueError as exc:
86
+ raise ConfigError(f"Invalid integer for {key}: {value}") from exc
87
+ continue
88
+ if key in {"active"}:
89
+ lowered = str(value).strip().lower()
90
+ if lowered in {"true", "1", "yes", "y", "on"}:
91
+ setattr(agent, key, True)
92
+ elif lowered in {"false", "0", "no", "n", "off"}:
93
+ setattr(agent, key, False)
94
+ else:
95
+ raise ConfigError(f"Invalid boolean for {key}: {value}")
96
+ continue
97
+ if key.startswith("model_settings."):
98
+ inner_key = key.removeprefix("model_settings.")
99
+ payload = json.loads(agent.model_settings_json or "{}")
100
+ payload[inner_key] = parse_jsonish(value)
101
+ agent.model_settings_json = json.dumps(payload)
102
+ continue
103
+ if key.startswith("safety_settings."):
104
+ inner_key = key.removeprefix("safety_settings.")
105
+ payload = json.loads(agent.safety_settings_json or "{}")
106
+ payload[inner_key] = parse_jsonish(value)
107
+ agent.safety_settings_json = json.dumps(payload)
108
+ continue
109
+ if key.startswith("commit_policy."):
110
+ inner_key = key.removeprefix("commit_policy.")
111
+ payload = json.loads(agent.commit_policy_json or "{}")
112
+ payload[inner_key] = parse_jsonish(value)
113
+ agent.commit_policy_json = json.dumps(payload)
114
+ continue
115
+ raise ConfigError(f"Unknown agent config key: {key}")
116
+ self.session.flush()
117
+ return agent
118
+
119
+ def delete_full(self, agent: AgentORM) -> None:
120
+ self.session.execute(delete(RunORM).where(RunORM.agent_id == agent.id))
121
+ self.session.delete(agent)
122
+ self.session.flush()
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.orm import Session
7
+
8
+ from orchestrator.git.service import GitService
9
+ from orchestrator.storage.models import ProjectRepoORM
10
+
11
+
12
+ class ProjectService:
13
+ def __init__(self, session: Session, git: GitService):
14
+ self.session = session
15
+ self.git = git
16
+
17
+ def add(
18
+ self,
19
+ name: str,
20
+ local_path: str,
21
+ default_branch: str = "main",
22
+ allowed_branch_pattern: str = "agent/*",
23
+ ) -> ProjectRepoORM:
24
+ repo_path = Path(local_path).expanduser().resolve()
25
+ self.git.ensure_git_repo(repo_path)
26
+ item = ProjectRepoORM(
27
+ name=name,
28
+ local_path=str(repo_path),
29
+ default_branch=default_branch,
30
+ allowed_branch_pattern=allowed_branch_pattern,
31
+ active=True,
32
+ )
33
+ self.session.add(item)
34
+ self.session.flush()
35
+ return item
36
+
37
+ def list(self) -> list[ProjectRepoORM]:
38
+ return list(self.session.scalars(select(ProjectRepoORM).order_by(ProjectRepoORM.id)).all())
39
+
40
+ def get(self, repo_id_or_name: str) -> ProjectRepoORM | None:
41
+ stmt = select(ProjectRepoORM).where(ProjectRepoORM.name == repo_id_or_name)
42
+ item = self.session.scalar(stmt)
43
+ if item:
44
+ return item
45
+ if repo_id_or_name.isdigit():
46
+ return self.session.get(ProjectRepoORM, int(repo_id_or_name))
47
+ return None
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.orm import Session
7
+
8
+ from orchestrator.config import AppPaths
9
+ from orchestrator.git.service import GitService
10
+ from orchestrator.storage.models import PromptSourceORM
11
+
12
+
13
+ class PromptSourceService:
14
+ def __init__(self, session: Session, paths: AppPaths, git: GitService):
15
+ self.session = session
16
+ self.paths = paths
17
+ self.git = git
18
+
19
+ def add(
20
+ self,
21
+ name: str,
22
+ repo_url: str,
23
+ branch: str = "main",
24
+ folder_scope: str | None = None,
25
+ sync_strategy: str = "ff-only",
26
+ clone_path: str | None = None,
27
+ ) -> PromptSourceORM:
28
+ clone = Path(clone_path) if clone_path else self.paths.prompt_sources_dir / name
29
+ item = PromptSourceORM(
30
+ name=name,
31
+ repo_url=repo_url,
32
+ local_clone_path=str(clone),
33
+ branch=branch,
34
+ folder_scope=folder_scope,
35
+ sync_strategy=sync_strategy,
36
+ active=True,
37
+ )
38
+ self.session.add(item)
39
+ self.session.flush()
40
+ return item
41
+
42
+ def list(self) -> list[PromptSourceORM]:
43
+ return list(self.session.scalars(select(PromptSourceORM).order_by(PromptSourceORM.id)).all())
44
+
45
+ def get(self, source_id_or_name: str) -> PromptSourceORM | None:
46
+ stmt = select(PromptSourceORM).where(PromptSourceORM.name == source_id_or_name)
47
+ source = self.session.scalar(stmt)
48
+ if source:
49
+ return source
50
+ if source_id_or_name.isdigit():
51
+ return self.session.get(PromptSourceORM, int(source_id_or_name))
52
+ return None
53
+
54
+ def sync(self, source: PromptSourceORM, bootstrap_missing_branch: bool = False) -> None:
55
+ path = Path(source.local_clone_path)
56
+ if not path.exists():
57
+ self.git.clone(source.repo_url, path, source.branch, bootstrap_missing_branch=bootstrap_missing_branch)
58
+ else:
59
+ self.git.ensure_git_repo(path)
60
+ self.git.pull(
61
+ path,
62
+ source.sync_strategy,
63
+ branch=source.branch,
64
+ bootstrap_missing_branch=bootstrap_missing_branch,
65
+ )
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ import json
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from orchestrator.storage.models import RunORM
10
+
11
+
12
+ class RunService:
13
+ def __init__(self, session: Session):
14
+ self.session = session
15
+
16
+ def create(self, agent_id: int, task_id: int | None, logs_path: str | None = None) -> RunORM:
17
+ run = RunORM(agent_id=agent_id, task_id=task_id, started_at=datetime.utcnow(), status="running", logs_path=logs_path)
18
+ self.session.add(run)
19
+ self.session.flush()
20
+ return run
21
+
22
+ def complete(
23
+ self,
24
+ run: RunORM,
25
+ status: str,
26
+ summary: str,
27
+ tool_invocations: list[dict] | None = None,
28
+ validation_results: list[dict] | None = None,
29
+ commit_sha: str | None = None,
30
+ branch_name: str | None = None,
31
+ ) -> None:
32
+ run.finished_at = datetime.utcnow()
33
+ run.status = status
34
+ run.summary = summary
35
+ run.tool_invocations_json = json.dumps(tool_invocations or [])
36
+ run.validation_results_json = json.dumps(validation_results or [])
37
+ run.commit_sha = commit_sha
38
+ run.branch_name = branch_name
39
+
40
+ def list(self, limit: int = 50) -> list[RunORM]:
41
+ stmt = select(RunORM).order_by(RunORM.id.desc()).limit(limit)
42
+ return list(self.session.scalars(stmt).all())
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from orchestrator.backends.base import ExecutionBackend
6
+ from orchestrator.domain.types import BackendContext, StepExecutionResult, TaskStep
7
+ from orchestrator.exceptions import BackendError
8
+ from orchestrator.storage.models import TaskORM
9
+
10
+
11
+ class StepExecutor:
12
+ def __init__(self, registry: dict[str, ExecutionBackend], backend_priority: list[str]):
13
+ self.registry = registry
14
+ self.backend_priority = backend_priority
15
+
16
+ def execute_steps(
17
+ self,
18
+ steps: list[TaskStep],
19
+ task: TaskORM,
20
+ project_path: Path,
21
+ prompt_root: Path,
22
+ context: BackendContext,
23
+ ) -> list[StepExecutionResult]:
24
+ if len(steps) > context.max_steps:
25
+ raise BackendError(f"Task has {len(steps)} steps but agent max_steps is {context.max_steps}")
26
+
27
+ results: list[StepExecutionResult] = []
28
+ for step in steps:
29
+ results.append(self.execute_step(step, task, project_path, prompt_root, context))
30
+ return results
31
+
32
+ def execute_step(
33
+ self,
34
+ step: TaskStep,
35
+ task: TaskORM,
36
+ project_path: Path,
37
+ prompt_root: Path,
38
+ context: BackendContext,
39
+ ) -> StepExecutionResult:
40
+ candidates = self._candidate_backends(step)
41
+ attempt_errors: list[str] = []
42
+ for backend in candidates:
43
+ backend_result = backend.execute_step(step, task, project_path, prompt_root, context)
44
+ if backend_result.success:
45
+ return StepExecutionResult(
46
+ step_id=step.id,
47
+ step_type=step.type,
48
+ backend=backend.name,
49
+ success=True,
50
+ summary=backend_result.summary,
51
+ stdout=backend_result.stdout,
52
+ stderr=backend_result.stderr,
53
+ tool_invocations=backend_result.tool_invocations,
54
+ )
55
+
56
+ detail = backend_result.stderr.strip() or backend_result.stdout.strip()
57
+ detail_snippet = f" details={detail[:240]}" if detail else ""
58
+ attempt_errors.append(f"{backend.name}: {backend_result.summary}{detail_snippet}")
59
+
60
+ attempts_text = " | ".join(attempt_errors) if attempt_errors else "no backend attempts executed"
61
+ raise BackendError(f"Step '{step.id}' failed. Attempts: {attempts_text}")
62
+
63
+ def _candidate_backends(self, step: TaskStep) -> list[ExecutionBackend]:
64
+ preferred = list(step.tool_preferences or [])
65
+ fallback = [name for name in self.backend_priority if name not in preferred]
66
+ candidates = preferred + fallback if preferred else list(self.backend_priority)
67
+
68
+ # Ensure enabled backends not present in backend_priority are still considered.
69
+ for backend_name in self.registry.keys():
70
+ if backend_name not in candidates:
71
+ candidates.append(backend_name)
72
+ unavailable: list[str] = []
73
+ unsupported: list[str] = []
74
+ selected: list[ExecutionBackend] = []
75
+ for backend_name in candidates:
76
+ backend = self.registry.get(backend_name)
77
+ if not backend:
78
+ continue
79
+ if not backend.supports(step):
80
+ unsupported.append(backend_name)
81
+ continue
82
+ if not backend.is_available():
83
+ unavailable.append(backend_name)
84
+ continue
85
+ selected.append(backend)
86
+ if selected:
87
+ return selected
88
+ suffix_parts: list[str] = []
89
+ if preferred:
90
+ suffix_parts.append(f"preferred={preferred}")
91
+ suffix_parts.append(f"fallback={fallback}")
92
+ if unavailable:
93
+ suffix_parts.append(f"unavailable={unavailable}")
94
+ if unsupported:
95
+ suffix_parts.append(f"unsupported={unsupported}")
96
+ suffix_parts.append(f"enabled={list(self.registry.keys())}")
97
+ suffix = " " + " ".join(suffix_parts) if suffix_parts else ""
98
+ raise BackendError(
99
+ f"No backend available for step '{step.id}' type='{step.type}' preferences={candidates}{suffix}"
100
+ )
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from orchestrator.domain.types import PromptTask
11
+ from orchestrator.prompts.parser import parse_task_file, parse_task_raw
12
+ from orchestrator.storage.models import AgentORM, PromptSourceORM, TaskORM
13
+
14
+
15
+ PRIORITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
16
+ VALID_TASK_STATUSES = {"todo", "ready", "in_progress", "done", "failed", "blocked"}
17
+
18
+
19
+ class TaskService:
20
+ def __init__(self, session: Session):
21
+ self.session = session
22
+
23
+ def discover_and_upsert(self, source: PromptSourceORM) -> int:
24
+ root = Path(source.local_clone_path)
25
+ if source.folder_scope:
26
+ normalized_scope = str(source.folder_scope).lstrip("/\\")
27
+ scan_root = root / normalized_scope
28
+ else:
29
+ scan_root = root
30
+ if not scan_root.exists():
31
+ return 0
32
+ files = sorted(
33
+ [*scan_root.rglob("*.md"), *scan_root.rglob("*.yaml"), *scan_root.rglob("*.yml")],
34
+ key=lambda p: str(p),
35
+ )
36
+ count = 0
37
+ for file in files:
38
+ rel = str(file.relative_to(root)).replace("\\", "/")
39
+ parsed = parse_task_file(file, rel)
40
+ self._upsert(source.id, parsed)
41
+ count += 1
42
+ return count
43
+
44
+ def _upsert(self, prompt_source_id: int, parsed: PromptTask) -> TaskORM:
45
+ stmt = select(TaskORM).where(
46
+ TaskORM.prompt_source_id == prompt_source_id,
47
+ TaskORM.source_path == parsed.source_path,
48
+ )
49
+ existing = self.session.scalar(stmt)
50
+ if existing:
51
+ existing.title = parsed.title
52
+ existing.description = parsed.description
53
+ existing.priority = parsed.priority
54
+ existing.labels_json = json.dumps(parsed.labels)
55
+ existing.dependencies_json = json.dumps(parsed.depends_on)
56
+ existing.target_paths_json = json.dumps(parsed.target_paths)
57
+ existing.acceptance_criteria_json = json.dumps(parsed.acceptance_criteria)
58
+ existing.target_repo = parsed.target_repo
59
+ existing.raw_content = parsed.raw_content
60
+ existing.last_seen_hash = parsed.last_seen_hash
61
+ existing.external_id = parsed.external_id
62
+ existing.updated_at = datetime.utcnow()
63
+ if existing.status in {"done", "failed", "blocked", "in_progress"}:
64
+ return existing
65
+ existing.status = parsed.status
66
+ return existing
67
+
68
+ item = TaskORM(
69
+ prompt_source_id=prompt_source_id,
70
+ external_id=parsed.external_id,
71
+ source_path=parsed.source_path,
72
+ title=parsed.title,
73
+ description=parsed.description,
74
+ labels_json=json.dumps(parsed.labels),
75
+ priority=parsed.priority,
76
+ status=parsed.status,
77
+ dependencies_json=json.dumps(parsed.depends_on),
78
+ target_paths_json=json.dumps(parsed.target_paths),
79
+ target_repo=parsed.target_repo,
80
+ acceptance_criteria_json=json.dumps(parsed.acceptance_criteria),
81
+ raw_content=parsed.raw_content,
82
+ last_seen_hash=parsed.last_seen_hash,
83
+ updated_at=datetime.utcnow(),
84
+ )
85
+ self.session.add(item)
86
+ self.session.flush()
87
+ return item
88
+
89
+ def list(self, status: str | None = None) -> list[TaskORM]:
90
+ stmt = select(TaskORM)
91
+ if status:
92
+ stmt = stmt.where(TaskORM.status == status)
93
+ tasks = list(self.session.scalars(stmt).all())
94
+ return sorted(tasks, key=lambda t: (PRIORITY_ORDER.get(t.priority, 99), t.updated_at))
95
+
96
+ def get(self, task_id: int) -> TaskORM | None:
97
+ return self.session.get(TaskORM, task_id)
98
+
99
+ def eligible_for_agent(
100
+ self,
101
+ agent: AgentORM,
102
+ project_name: str | None = None,
103
+ exclude_task_ids: set[int] | None = None,
104
+ ) -> list[TaskORM]:
105
+ tasks = self.list(status=None)
106
+ by_external = {t.external_id: t for t in tasks if t.external_id}
107
+ excluded = exclude_task_ids or set()
108
+ eligible: list[TaskORM] = []
109
+ for task in tasks:
110
+ if task.prompt_source_id != agent.prompt_source_id:
111
+ continue
112
+ if task.id in excluded:
113
+ continue
114
+ if task.status not in {"todo", "ready"}:
115
+ continue
116
+ if task.target_repo and task.target_repo != "*":
117
+ if not project_name or task.target_repo != project_name:
118
+ continue
119
+ deps = json.loads(task.dependencies_json or "[]")
120
+ if deps:
121
+ resolved = all(by_external.get(dep) and by_external[dep].status == "done" for dep in deps)
122
+ if not resolved:
123
+ continue
124
+ eligible.append(task)
125
+ return eligible
126
+
127
+ def select_next_for_agent(
128
+ self,
129
+ agent: AgentORM,
130
+ project_name: str | None = None,
131
+ exclude_task_ids: set[int] | None = None,
132
+ ) -> TaskORM | None:
133
+ eligible = self.eligible_for_agent(
134
+ agent,
135
+ project_name=project_name,
136
+ exclude_task_ids=exclude_task_ids,
137
+ )
138
+ return eligible[0] if eligible else None
139
+
140
+ def mark_status(self, task: TaskORM, status: str) -> None:
141
+ if status not in VALID_TASK_STATUSES:
142
+ raise ValueError(f"Invalid task status: {status}")
143
+ task.status = status
144
+ task.updated_at = datetime.utcnow()
145
+
146
+ def set_status_by_id(self, task_id: int, status: str) -> TaskORM | None:
147
+ task = self.get(task_id)
148
+ if not task:
149
+ return None
150
+ self.mark_status(task, status)
151
+ return task
152
+
153
+ def parse_raw_task(self, task: TaskORM) -> PromptTask:
154
+ suffix = Path(task.source_path).suffix or ".md"
155
+ return parse_task_raw(task.raw_content, rel_path=task.source_path, suffix=suffix)
@@ -0,0 +1 @@
1
+ """Storage layer and ORM models."""
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+
5
+ from sqlalchemy import create_engine
6
+ from sqlalchemy.orm import Session
7
+
8
+ from orchestrator.storage.models import Base
9
+
10
+
11
+ def make_engine(db_file: str):
12
+ return create_engine(f"sqlite+pysqlite:///{db_file}", future=True)
13
+
14
+
15
+ def init_db(engine) -> None:
16
+ Base.metadata.create_all(engine)
17
+
18
+
19
+ @contextmanager
20
+ def session_scope(engine):
21
+ session = Session(engine)
22
+ try:
23
+ yield session
24
+ session.commit()
25
+ except Exception:
26
+ session.rollback()
27
+ raise
28
+ finally:
29
+ session.close()
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ pass
11
+
12
+
13
+ class PromptSourceORM(Base):
14
+ __tablename__ = "prompt_sources"
15
+
16
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
17
+ name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
18
+ repo_url: Mapped[str] = mapped_column(String(500), nullable=False)
19
+ local_clone_path: Mapped[str] = mapped_column(String(500), nullable=False)
20
+ branch: Mapped[str] = mapped_column(String(128), default="main")
21
+ folder_scope: Mapped[str | None] = mapped_column(String(500), nullable=True)
22
+ sync_strategy: Mapped[str] = mapped_column(String(32), default="ff-only")
23
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
24
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
25
+
26
+
27
+ class ProjectRepoORM(Base):
28
+ __tablename__ = "project_repos"
29
+
30
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
31
+ name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
32
+ local_path: Mapped[str] = mapped_column(String(500), nullable=False)
33
+ default_branch: Mapped[str] = mapped_column(String(128), default="main")
34
+ allowed_branch_pattern: Mapped[str] = mapped_column(String(200), default="agent/*")
35
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
36
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
37
+
38
+
39
+ class AgentORM(Base):
40
+ __tablename__ = "agents"
41
+
42
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
43
+ name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
44
+ prompt_source_id: Mapped[int] = mapped_column(ForeignKey("prompt_sources.id"), nullable=False)
45
+ project_repo_id: Mapped[int] = mapped_column(ForeignKey("project_repos.id"), nullable=False)
46
+ task_selector_strategy: Mapped[str] = mapped_column(String(64), default="priority_then_oldest")
47
+ execution_backend: Mapped[str] = mapped_column(String(64), default="mock")
48
+ model_settings_json: Mapped[str] = mapped_column(Text, default="{}")
49
+ validation_policy_json: Mapped[str] = mapped_column(Text, default="[]")
50
+ commit_policy_json: Mapped[str] = mapped_column(Text, default="{}")
51
+ push_policy: Mapped[str] = mapped_column(String(32), default="never")
52
+ autonomy_level: Mapped[str] = mapped_column(String(32), default="semi-auto")
53
+ max_steps: Mapped[int] = mapped_column(Integer, default=20)
54
+ safety_settings_json: Mapped[str] = mapped_column(Text, default="{}")
55
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
56
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
57
+
58
+
59
+ class TaskORM(Base):
60
+ __tablename__ = "tasks"
61
+ __table_args__ = (UniqueConstraint("prompt_source_id", "source_path", name="uq_task_source_path"),)
62
+
63
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
64
+ prompt_source_id: Mapped[int] = mapped_column(ForeignKey("prompt_sources.id"), nullable=False)
65
+ external_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
66
+ source_path: Mapped[str] = mapped_column(String(600), nullable=False)
67
+ title: Mapped[str] = mapped_column(String(300), nullable=False)
68
+ description: Mapped[str] = mapped_column(Text, default="")
69
+ labels_json: Mapped[str] = mapped_column(Text, default="[]")
70
+ priority: Mapped[str] = mapped_column(String(32), default="medium")
71
+ status: Mapped[str] = mapped_column(String(32), default="todo")
72
+ dependencies_json: Mapped[str] = mapped_column(Text, default="[]")
73
+ target_paths_json: Mapped[str] = mapped_column(Text, default="[]")
74
+ target_repo: Mapped[str | None] = mapped_column(String(120), nullable=True)
75
+ acceptance_criteria_json: Mapped[str] = mapped_column(Text, default="[]")
76
+ raw_content: Mapped[str] = mapped_column(Text, default="")
77
+ last_seen_hash: Mapped[str] = mapped_column(String(128), nullable=False)
78
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
79
+
80
+
81
+ class RunORM(Base):
82
+ __tablename__ = "runs"
83
+
84
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
85
+ agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), nullable=False)
86
+ task_id: Mapped[int | None] = mapped_column(ForeignKey("tasks.id"), nullable=True)
87
+ started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
88
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
89
+ status: Mapped[str] = mapped_column(String(32), default="running")
90
+ summary: Mapped[str] = mapped_column(Text, default="")
91
+ tool_invocations_json: Mapped[str] = mapped_column(Text, default="[]")
92
+ validation_results_json: Mapped[str] = mapped_column(Text, default="[]")
93
+ commit_sha: Mapped[str | None] = mapped_column(String(64), nullable=True)
94
+ branch_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
95
+ logs_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
@@ -0,0 +1 @@
1
+ """Utility helpers."""