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.
- execforge-0.1.0.dist-info/METADATA +367 -0
- execforge-0.1.0.dist-info/RECORD +44 -0
- execforge-0.1.0.dist-info/WHEEL +5 -0
- execforge-0.1.0.dist-info/entry_points.txt +5 -0
- execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- execforge-0.1.0.dist-info/top_level.txt +1 -0
- orchestrator/__init__.py +4 -0
- orchestrator/__main__.py +5 -0
- orchestrator/backends/__init__.py +1 -0
- orchestrator/backends/base.py +29 -0
- orchestrator/backends/factory.py +53 -0
- orchestrator/backends/llm_cli_backend.py +87 -0
- orchestrator/backends/mock_backend.py +34 -0
- orchestrator/backends/shell_backend.py +49 -0
- orchestrator/cli/__init__.py +1 -0
- orchestrator/cli/main.py +971 -0
- orchestrator/config.py +272 -0
- orchestrator/domain/__init__.py +1 -0
- orchestrator/domain/types.py +77 -0
- orchestrator/exceptions.py +18 -0
- orchestrator/git/__init__.py +1 -0
- orchestrator/git/service.py +202 -0
- orchestrator/logging_setup.py +53 -0
- orchestrator/prompts/__init__.py +1 -0
- orchestrator/prompts/parser.py +91 -0
- orchestrator/reporting/__init__.py +1 -0
- orchestrator/reporting/console.py +197 -0
- orchestrator/reporting/events.py +44 -0
- orchestrator/reporting/selection_result.py +15 -0
- orchestrator/services/__init__.py +1 -0
- orchestrator/services/agent_runner.py +831 -0
- orchestrator/services/agent_service.py +122 -0
- orchestrator/services/project_service.py +47 -0
- orchestrator/services/prompt_source_service.py +65 -0
- orchestrator/services/run_service.py +42 -0
- orchestrator/services/step_executor.py +100 -0
- orchestrator/services/task_service.py +155 -0
- orchestrator/storage/__init__.py +1 -0
- orchestrator/storage/db.py +29 -0
- orchestrator/storage/models.py +95 -0
- orchestrator/utils/__init__.py +1 -0
- orchestrator/utils/process.py +44 -0
- orchestrator/validation/__init__.py +1 -0
- 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."""
|