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,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from orchestrator.domain.types import PromptTask, TaskGitPolicy, TaskStep
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_steps(metadata: dict, body: str) -> list[TaskStep]:
|
|
16
|
+
steps: list[TaskStep] = []
|
|
17
|
+
for idx, item in enumerate(list(metadata.get("steps", []) or []), start=1):
|
|
18
|
+
if not isinstance(item, dict):
|
|
19
|
+
continue
|
|
20
|
+
step_id = str(item.get("id") or f"step-{idx}")
|
|
21
|
+
step_type = str(item.get("type") or "llm_summary")
|
|
22
|
+
steps.append(
|
|
23
|
+
TaskStep(
|
|
24
|
+
id=step_id,
|
|
25
|
+
type=step_type,
|
|
26
|
+
tool_preferences=list(item.get("tool_preferences", []) or []),
|
|
27
|
+
prompt_file=item.get("prompt_file"),
|
|
28
|
+
prompt_inline=item.get("prompt_inline"),
|
|
29
|
+
command=item.get("command"),
|
|
30
|
+
metadata={k: v for k, v in item.items() if k not in {"id", "type", "tool_preferences", "prompt_file", "prompt_inline", "command"}},
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if steps:
|
|
35
|
+
return steps
|
|
36
|
+
|
|
37
|
+
# Backward-compatible fallback: a single summary step from body.
|
|
38
|
+
if body.strip():
|
|
39
|
+
return [TaskStep(id="default", type="llm_summary", prompt_inline=body.strip())]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_git_policy(metadata: dict) -> TaskGitPolicy:
|
|
44
|
+
data = metadata.get("git") or {}
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
return TaskGitPolicy()
|
|
47
|
+
return TaskGitPolicy(
|
|
48
|
+
base_branch=data.get("base_branch"),
|
|
49
|
+
work_branch=data.get("work_branch"),
|
|
50
|
+
push_on_success=data.get("push_on_success"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_task_raw(raw: str, rel_path: str, suffix: str) -> PromptTask:
|
|
55
|
+
metadata: dict = {}
|
|
56
|
+
body = raw
|
|
57
|
+
if suffix.lower() == ".md":
|
|
58
|
+
match = FRONTMATTER_RE.match(raw)
|
|
59
|
+
if match:
|
|
60
|
+
metadata = yaml.safe_load(match.group(1)) or {}
|
|
61
|
+
body = match.group(2).strip()
|
|
62
|
+
else:
|
|
63
|
+
metadata = yaml.safe_load(raw) or {}
|
|
64
|
+
body = str(metadata.get("instructions") or metadata.get("description") or "").strip()
|
|
65
|
+
|
|
66
|
+
title = metadata.get("title") or Path(rel_path).stem
|
|
67
|
+
steps = _parse_steps(metadata, body)
|
|
68
|
+
git = _parse_git_policy(metadata)
|
|
69
|
+
task = PromptTask(
|
|
70
|
+
external_id=metadata.get("id"),
|
|
71
|
+
source_path=rel_path,
|
|
72
|
+
title=title,
|
|
73
|
+
description=body,
|
|
74
|
+
priority=metadata.get("priority", "medium"),
|
|
75
|
+
status=metadata.get("status", "todo"),
|
|
76
|
+
labels=list(metadata.get("labels", []) or []),
|
|
77
|
+
target_repo=metadata.get("target_repo"),
|
|
78
|
+
target_paths=list(metadata.get("target_paths", []) or []),
|
|
79
|
+
depends_on=list(metadata.get("depends_on", []) or []),
|
|
80
|
+
acceptance_criteria=list(metadata.get("acceptance_criteria", []) or []),
|
|
81
|
+
steps=steps,
|
|
82
|
+
git=git,
|
|
83
|
+
raw_content=raw,
|
|
84
|
+
last_seen_hash=sha256(raw.encode("utf-8")).hexdigest(),
|
|
85
|
+
)
|
|
86
|
+
return task
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_task_file(path: Path, rel_path: str) -> PromptTask:
|
|
90
|
+
raw = path.read_text(encoding="utf-8")
|
|
91
|
+
return parse_task_raw(raw, rel_path=rel_path, suffix=path.suffix)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Console and event reporting for runtime execution."""
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from orchestrator.reporting.events import LogEvent, clean_context
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _fmt_time(value: str | datetime | None) -> str:
|
|
10
|
+
if isinstance(value, datetime):
|
|
11
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
12
|
+
if isinstance(value, str) and value:
|
|
13
|
+
return value
|
|
14
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class ConsoleReporter:
|
|
19
|
+
mode: str = "default" # default | verbose | debug
|
|
20
|
+
warnings_in_run: int = 0
|
|
21
|
+
|
|
22
|
+
def _print(self, text: str = "") -> None:
|
|
23
|
+
print(text)
|
|
24
|
+
|
|
25
|
+
def render(self, event: LogEvent) -> None:
|
|
26
|
+
if self.mode == "debug":
|
|
27
|
+
self._print(str(event.to_dict()))
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
name = event.name
|
|
31
|
+
context = clean_context(event.context)
|
|
32
|
+
|
|
33
|
+
if name == "loop_started":
|
|
34
|
+
self._print("=" * 60)
|
|
35
|
+
self._print("Execforge Loop Started")
|
|
36
|
+
self._print(f" Time: {_fmt_time(context.get('time'))}")
|
|
37
|
+
self._print(f" Agent: {context.get('agent')}")
|
|
38
|
+
self._print(f" Project: {context.get('project')}")
|
|
39
|
+
self._print(f" Prompt Source: {context.get('prompt_source')}")
|
|
40
|
+
self._print(f" Interval: {context.get('interval_seconds')}s")
|
|
41
|
+
self._print(f" Only New Prompts: {str(context.get('only_new_prompts', True)).lower()}")
|
|
42
|
+
self._print(f" Reset Only New Baseline: {str(context.get('reset_only_new_baseline', False)).lower()}")
|
|
43
|
+
self._print(f" Initial Excluded Tasks: {context.get('initial_excluded', 0)}")
|
|
44
|
+
self._print(f" Allow Dirty Working Tree: {str(context.get('allow_dirty_worktree', False)).lower()}")
|
|
45
|
+
if context.get("branch_strategy"):
|
|
46
|
+
self._print(f" Branch Strategy: {context.get('branch_strategy')}")
|
|
47
|
+
self._print("=" * 60)
|
|
48
|
+
self._print("")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if name == "run_started":
|
|
52
|
+
self.warnings_in_run = 0
|
|
53
|
+
self._print("-" * 60)
|
|
54
|
+
self._print("Execforge Run")
|
|
55
|
+
self._print(f" Run: {context.get('run_id')}")
|
|
56
|
+
self._print(f" Time: {_fmt_time(context.get('time'))}")
|
|
57
|
+
self._print(f" Agent: {context.get('agent')}")
|
|
58
|
+
self._print(f" Project: {context.get('project')}")
|
|
59
|
+
self._print(f" Prompt Source: {context.get('prompt_source')}")
|
|
60
|
+
self._print("-" * 60)
|
|
61
|
+
self._print("")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if name in {"prompt_sync_started", "repo_validate_started", "task_select_started", "branch_prepare_started", "steps_started"}:
|
|
65
|
+
idx = event.phase_index or 0
|
|
66
|
+
total = event.phase_total or 0
|
|
67
|
+
self._print(f"[{idx}/{total}] {event.title}...")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if name == "prompt_synced":
|
|
71
|
+
self._print(f" Found {context.get('discovered_tasks', 0)} task")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if name == "repo_validated":
|
|
75
|
+
branch = context.get("current_branch")
|
|
76
|
+
if branch:
|
|
77
|
+
self._print(f" Current branch: {branch}")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if name == "task_selection_completed":
|
|
81
|
+
if context.get("selected_task_id"):
|
|
82
|
+
self._print(f" Selected: {context.get('selected_task_id')}")
|
|
83
|
+
else:
|
|
84
|
+
self._print(" No task selected")
|
|
85
|
+
self._print(f" Reason: {context.get('reason')}")
|
|
86
|
+
self._print(f" Eligible tasks: {context.get('eligible_count', 0)}")
|
|
87
|
+
self._print(f" Excluded tasks: {context.get('excluded_count', 0)}")
|
|
88
|
+
if context.get("next_hint"):
|
|
89
|
+
self._print(f" Next: {context.get('next_hint')}")
|
|
90
|
+
if self.mode == "verbose":
|
|
91
|
+
if context.get("code"):
|
|
92
|
+
self._print(f" Outcome: {context.get('code')}")
|
|
93
|
+
self._print(f" Discovered tasks: {context.get('discovered_count', 0)}")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if name == "branch_prepared":
|
|
97
|
+
if context.get("base_branch"):
|
|
98
|
+
self._print(f" Base: {context.get('base_branch')}")
|
|
99
|
+
if context.get("branch"):
|
|
100
|
+
self._print(f" Branch: {context.get('branch')}")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if name == "step_completed":
|
|
104
|
+
i = context.get("step_index")
|
|
105
|
+
total = context.get("step_total")
|
|
106
|
+
step = context.get("step")
|
|
107
|
+
backend = context.get("backend")
|
|
108
|
+
symbol = context.get("symbol", "✓")
|
|
109
|
+
self._print(f" [{i}/{total}] {symbol} {step:<16} {backend}")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if name == "step_failed":
|
|
113
|
+
i = context.get("step_index", "?")
|
|
114
|
+
total = context.get("step_total", "?")
|
|
115
|
+
step = context.get("step", "unknown")
|
|
116
|
+
backend = context.get("backend", "runtime")
|
|
117
|
+
self._print(f" [{i}/{total}] ✗ {step:<16} {backend}")
|
|
118
|
+
self._print("")
|
|
119
|
+
if context.get("base_branch"):
|
|
120
|
+
self._print(f" Base: {context.get('base_branch')}")
|
|
121
|
+
if context.get("branch"):
|
|
122
|
+
self._print(f" Branch: {context.get('branch')}")
|
|
123
|
+
if context.get("task_id"):
|
|
124
|
+
self._print(f" Task: {context.get('task_id')}")
|
|
125
|
+
if context.get("error"):
|
|
126
|
+
self._print(f" Error: {context.get('error')}")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if name == "warning":
|
|
130
|
+
self.warnings_in_run += 1
|
|
131
|
+
self._print(f"⚠ {event.message}")
|
|
132
|
+
if context.get("branch"):
|
|
133
|
+
self._print(f" Branch: {context.get('branch')}")
|
|
134
|
+
if context.get("task_id"):
|
|
135
|
+
self._print(f" Task: {context.get('task_id')}")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if name == "run_noop":
|
|
139
|
+
self._print("")
|
|
140
|
+
self._print("Run complete")
|
|
141
|
+
self._print(" Status: noop")
|
|
142
|
+
self._print(f" Reason: {context.get('reason', 'no actionable task found')}")
|
|
143
|
+
if context.get("next_hint"):
|
|
144
|
+
self._print(f" Next: {context.get('next_hint')}")
|
|
145
|
+
if context.get("project"):
|
|
146
|
+
self._print(f" Project: {context.get('project')}")
|
|
147
|
+
if context.get("warnings") is not None:
|
|
148
|
+
self._print(f" Warnings: {context.get('warnings')}")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if name == "run_completed":
|
|
152
|
+
self._print("")
|
|
153
|
+
self._print("Run complete")
|
|
154
|
+
self._print(f" Status: {context.get('status', 'success')}")
|
|
155
|
+
if context.get("reason"):
|
|
156
|
+
self._print(f" Reason: {context.get('reason')}")
|
|
157
|
+
if context.get("task_id"):
|
|
158
|
+
self._print(f" Task: {context.get('task_id')}")
|
|
159
|
+
if context.get("branch"):
|
|
160
|
+
self._print(f" Branch: {context.get('branch')}")
|
|
161
|
+
if context.get("push_enabled") is not None:
|
|
162
|
+
self._print(f" Push Enabled: {str(context.get('push_enabled')).lower()}")
|
|
163
|
+
if context.get("steps_total") is not None and context.get("steps_passed") is not None:
|
|
164
|
+
self._print(f" Steps: {context.get('steps_passed')}/{context.get('steps_total')} passed")
|
|
165
|
+
warnings = context.get("warnings", self.warnings_in_run)
|
|
166
|
+
self._print(f" Warnings: {warnings}")
|
|
167
|
+
if context.get("log_path"):
|
|
168
|
+
self._print(f" Log File: {context.get('log_path')}")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
if name == "run_failed":
|
|
172
|
+
if self.mode == "verbose":
|
|
173
|
+
self._print(f" Failure reason: {context.get('reason')}")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
if name == "loop_waiting":
|
|
177
|
+
interval = int(context.get("interval_seconds", 0) or 0)
|
|
178
|
+
next_at = context.get("next_run_at")
|
|
179
|
+
if not next_at:
|
|
180
|
+
next_at = _fmt_time(datetime.now() + timedelta(seconds=interval))
|
|
181
|
+
self._print("")
|
|
182
|
+
self._print("Waiting for next poll...")
|
|
183
|
+
self._print(f" Next run in: {interval}s")
|
|
184
|
+
self._print(f" Next run at: {next_at}")
|
|
185
|
+
self._print("")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
if self.mode == "verbose" and event.message:
|
|
189
|
+
self._print(f" {event.message}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class NullReporter(ConsoleReporter):
|
|
193
|
+
def __init__(self):
|
|
194
|
+
super().__init__(mode="default")
|
|
195
|
+
|
|
196
|
+
def render(self, event: LogEvent) -> None:
|
|
197
|
+
return
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clean_context(data: dict[str, Any] | None) -> dict[str, Any]:
|
|
8
|
+
if not data:
|
|
9
|
+
return {}
|
|
10
|
+
cleaned: dict[str, Any] = {}
|
|
11
|
+
for key, value in data.items():
|
|
12
|
+
if value is None:
|
|
13
|
+
continue
|
|
14
|
+
if isinstance(value, str) and value.strip() == "":
|
|
15
|
+
continue
|
|
16
|
+
cleaned[key] = value
|
|
17
|
+
return cleaned
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class LogEvent:
|
|
22
|
+
name: str
|
|
23
|
+
level: str = "info"
|
|
24
|
+
phase_index: int | None = None
|
|
25
|
+
phase_total: int | None = None
|
|
26
|
+
title: str | None = None
|
|
27
|
+
message: str | None = None
|
|
28
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
payload: dict[str, Any] = {
|
|
32
|
+
"name": self.name,
|
|
33
|
+
"level": self.level,
|
|
34
|
+
}
|
|
35
|
+
if self.phase_index is not None:
|
|
36
|
+
payload["phase_index"] = self.phase_index
|
|
37
|
+
if self.phase_total is not None:
|
|
38
|
+
payload["phase_total"] = self.phase_total
|
|
39
|
+
if self.title:
|
|
40
|
+
payload["title"] = self.title
|
|
41
|
+
if self.message:
|
|
42
|
+
payload["message"] = self.message
|
|
43
|
+
payload["context"] = clean_context(self.context)
|
|
44
|
+
return payload
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class SelectionOutcome:
|
|
8
|
+
code: str
|
|
9
|
+
reason: str
|
|
10
|
+
next_hint: str | None = None
|
|
11
|
+
selected_task_id: str | None = None
|
|
12
|
+
eligible_count: int = 0
|
|
13
|
+
excluded_count: int = 0
|
|
14
|
+
discovered_count: int = 0
|
|
15
|
+
total_tasks_for_source: int = 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application service layer."""
|