forgeo-cli 0.3.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.
- forgeo/__init__.py +10 -0
- forgeo/__main__.py +6 -0
- forgeo/agent.py +332 -0
- forgeo/backlog.py +206 -0
- forgeo/central.py +620 -0
- forgeo/cli.py +759 -0
- forgeo/config.py +36 -0
- forgeo/daemon.py +209 -0
- forgeo/forgeo.py +446 -0
- forgeo/git.py +100 -0
- forgeo/instances.py +187 -0
- forgeo/io.py +30 -0
- forgeo/models.py +252 -0
- forgeo/notify.py +77 -0
- forgeo/runs.py +73 -0
- forgeo/setup.py +185 -0
- forgeo/web/central/central.css +380 -0
- forgeo/web/central/central.js +780 -0
- forgeo/web/central/index.html +41 -0
- forgeo/web/central/instance.html +189 -0
- forgeo/web/style.css +656 -0
- forgeo/web_common.py +92 -0
- forgeo_cli-0.3.0.dist-info/METADATA +146 -0
- forgeo_cli-0.3.0.dist-info/RECORD +27 -0
- forgeo_cli-0.3.0.dist-info/WHEEL +4 -0
- forgeo_cli-0.3.0.dist-info/entry_points.txt +2 -0
- forgeo_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
forgeo/git.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Git operations for Forgeo.
|
|
2
|
+
|
|
3
|
+
Everything happens on a single branch (``main`` by default): commit whatever
|
|
4
|
+
the agent changed, then push. No branches, no PRs, no merge strategies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitError(RuntimeError):
|
|
16
|
+
"""Raised when a git command cannot be executed or fails."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitManager:
|
|
20
|
+
"""Run git commands against a single repository (via the git CLI)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self, repo_path: str | Path, *, timeout_seconds: float = 120
|
|
24
|
+
) -> None:
|
|
25
|
+
self.repo_path = Path(repo_path)
|
|
26
|
+
self.timeout_seconds = timeout_seconds
|
|
27
|
+
|
|
28
|
+
def _run(self, *args: str, check: bool = True) -> str:
|
|
29
|
+
"""Execute ``git -C <repo> <args>`` and return combined output."""
|
|
30
|
+
if not shutil.which("git"):
|
|
31
|
+
raise GitError("the 'git' executable was not found on PATH")
|
|
32
|
+
try:
|
|
33
|
+
proc = subprocess.run(
|
|
34
|
+
["git", "-C", str(self.repo_path), *args],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
check=False,
|
|
38
|
+
timeout=self.timeout_seconds,
|
|
39
|
+
)
|
|
40
|
+
except subprocess.TimeoutExpired as exc:
|
|
41
|
+
raise GitError(f"git {args[0]} timed out") from exc
|
|
42
|
+
if check and proc.returncode != 0:
|
|
43
|
+
raise GitError(
|
|
44
|
+
f"git {args[0]} failed (exit {proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
|
|
45
|
+
)
|
|
46
|
+
return proc.stdout.strip()
|
|
47
|
+
|
|
48
|
+
def ensure_branch(self, branch: str) -> None:
|
|
49
|
+
"""Switch to ``branch``, creating it from HEAD when it does not exist."""
|
|
50
|
+
try:
|
|
51
|
+
self._run("rev-parse", "--verify", f"refs/heads/{branch}")
|
|
52
|
+
except GitError:
|
|
53
|
+
self._run("switch", "-c", branch)
|
|
54
|
+
return
|
|
55
|
+
self._run("switch", branch)
|
|
56
|
+
|
|
57
|
+
def is_clean(self) -> bool:
|
|
58
|
+
"""Return whether the working tree has no changes."""
|
|
59
|
+
return not bool(self._run("status", "--porcelain"))
|
|
60
|
+
|
|
61
|
+
def commit_all(self, message: str) -> str | None:
|
|
62
|
+
"""Stage all changes and commit; returns the short sha, or ``None`` if nothing to commit."""
|
|
63
|
+
self._run("add", "-A")
|
|
64
|
+
if not bool(self._run("status", "--porcelain")):
|
|
65
|
+
return None
|
|
66
|
+
self._run("commit", "-m", message)
|
|
67
|
+
return self._run("rev-parse", "--short", "HEAD")
|
|
68
|
+
|
|
69
|
+
def push(self, remote: str, branch: str) -> None:
|
|
70
|
+
"""Push ``branch`` to ``remote``."""
|
|
71
|
+
self._run("push", remote, branch)
|
|
72
|
+
|
|
73
|
+
def reset_hard(self) -> None:
|
|
74
|
+
"""Discard all uncommitted changes in the working tree.
|
|
75
|
+
|
|
76
|
+
Reverts tracked files and removes untracked ones (Forgeo only
|
|
77
|
+
ever discards work after having verified the tree was clean, so
|
|
78
|
+
everything removed here was produced by the agent).
|
|
79
|
+
"""
|
|
80
|
+
self._run("reset", "--hard", "HEAD")
|
|
81
|
+
self._run("clean", "-fd")
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
# Async wrappers (run git in a worker thread) #
|
|
85
|
+
# ------------------------------------------------------------------ #
|
|
86
|
+
|
|
87
|
+
async def a_ensure_branch(self, branch: str) -> None:
|
|
88
|
+
await asyncio.to_thread(self.ensure_branch, branch)
|
|
89
|
+
|
|
90
|
+
async def a_is_clean(self) -> bool:
|
|
91
|
+
return await asyncio.to_thread(self.is_clean)
|
|
92
|
+
|
|
93
|
+
async def a_commit_all(self, message: str) -> str | None:
|
|
94
|
+
return await asyncio.to_thread(self.commit_all, message)
|
|
95
|
+
|
|
96
|
+
async def a_push(self, remote: str, branch: str) -> None:
|
|
97
|
+
await asyncio.to_thread(self.push, remote, branch)
|
|
98
|
+
|
|
99
|
+
async def a_reset_hard(self) -> None:
|
|
100
|
+
await asyncio.to_thread(self.reset_hard)
|
forgeo/instances.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Instance registry: a stable name for every configured forgeo.
|
|
2
|
+
|
|
3
|
+
Each forgeo is configured by its own ``forgeo.yaml`` and runs as its own
|
|
4
|
+
daemon process, but nothing on the host knows how many factories exist or
|
|
5
|
+
how to find their configs. The registry gives every forgeo a unique name
|
|
6
|
+
mapped to the absolute path of its ``forgeo.yaml``, so the CLI can resolve
|
|
7
|
+
a config by name and a single command can enumerate every forgeo.
|
|
8
|
+
|
|
9
|
+
The registry is a YAML file mapping instance names to config paths. It
|
|
10
|
+
lives at ``$FORGEO_REGISTRY`` or ``~/.config/forgeo/instances.yaml`` and is
|
|
11
|
+
created on the first write. Writes are atomic (temp file + rename), so a
|
|
12
|
+
crash mid-write never corrupts the registry.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from forgeo.config import load_config
|
|
25
|
+
from forgeo.daemon import is_lock_held
|
|
26
|
+
from forgeo.io import atomic_write_text
|
|
27
|
+
from forgeo.models import ForgeoConfig
|
|
28
|
+
|
|
29
|
+
INSTANCE_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
30
|
+
|
|
31
|
+
DEFAULT_REGISTRY = Path.home() / ".config" / "forgeo" / "instances.yaml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def registry_path() -> Path:
|
|
35
|
+
"""Path of the registry file: ``$FORGEO_REGISTRY`` or the default."""
|
|
36
|
+
env = os.environ.get("FORGEO_REGISTRY")
|
|
37
|
+
if env:
|
|
38
|
+
return Path(env).expanduser()
|
|
39
|
+
return DEFAULT_REGISTRY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_registry() -> dict[str, str]:
|
|
43
|
+
"""Load the registry as ``{instance name: absolute config path}``.
|
|
44
|
+
|
|
45
|
+
A missing or unreadable file reads as an empty registry.
|
|
46
|
+
"""
|
|
47
|
+
path = registry_path()
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
51
|
+
if not isinstance(payload, dict):
|
|
52
|
+
return {}
|
|
53
|
+
return {
|
|
54
|
+
str(name): str(config)
|
|
55
|
+
for name, config in payload.items()
|
|
56
|
+
if isinstance(name, str) and isinstance(config, str)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_registry(registry: dict[str, str]) -> None:
|
|
61
|
+
"""Persist ``registry`` atomically (temp file + rename)."""
|
|
62
|
+
atomic_write_text(
|
|
63
|
+
registry_path(),
|
|
64
|
+
yaml.safe_dump(dict(sorted(registry.items())), sort_keys=False),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_instance(name: str) -> Path | None:
|
|
69
|
+
"""Absolute config path for ``name``, or ``None`` when it is not registered."""
|
|
70
|
+
config_path = load_registry().get(name)
|
|
71
|
+
return Path(config_path) if config_path else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_name(name: str) -> None:
|
|
75
|
+
"""Raise ``ValueError`` unless ``name`` matches the allowed pattern."""
|
|
76
|
+
if not INSTANCE_NAME_RE.fullmatch(name):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"invalid instance name {name!r}: must match ^[a-zA-Z0-9._-]+$"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_instance(name: str, config_path: str | Path) -> str:
|
|
83
|
+
"""Register ``name`` -> the absolute path of ``config_path``.
|
|
84
|
+
|
|
85
|
+
Validates that the name is well-formed, that it is not already
|
|
86
|
+
registered, and that the config file loads. Returns ``name``.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: Invalid or duplicate name, or a config that fails to
|
|
90
|
+
load (a bad payload raises pydantic's ``ValidationError``).
|
|
91
|
+
FileNotFoundError: The config file does not exist.
|
|
92
|
+
"""
|
|
93
|
+
_validate_name(name)
|
|
94
|
+
registry = load_registry()
|
|
95
|
+
if name in registry:
|
|
96
|
+
raise ValueError(f"instance {name!r} is already registered")
|
|
97
|
+
absolute = Path(config_path).expanduser().resolve()
|
|
98
|
+
load_config(absolute)
|
|
99
|
+
registry[name] = str(absolute)
|
|
100
|
+
save_registry(registry)
|
|
101
|
+
return name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ensure_registered(name: str, config_path: str | Path) -> bool:
|
|
105
|
+
"""Register ``name`` -> the absolute path of ``config_path`` when missing.
|
|
106
|
+
|
|
107
|
+
Unlike :func:`add_instance` this never raises: a name that is already
|
|
108
|
+
registered (whatever it points at), fails the instance-name pattern, or
|
|
109
|
+
maps to a config that cannot be loaded is left untouched. Returns
|
|
110
|
+
``True`` only when the instance was newly registered.
|
|
111
|
+
"""
|
|
112
|
+
if name in load_registry():
|
|
113
|
+
return False
|
|
114
|
+
if not INSTANCE_NAME_RE.fullmatch(name):
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
add_instance(name, config_path)
|
|
118
|
+
except (ValueError, FileNotFoundError, yaml.YAMLError):
|
|
119
|
+
return False
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove_instance(name: str) -> bool:
|
|
124
|
+
"""Unregister ``name``; never touches its config file or repository.
|
|
125
|
+
|
|
126
|
+
Returns ``True`` when the instance was registered and removed.
|
|
127
|
+
"""
|
|
128
|
+
registry = load_registry()
|
|
129
|
+
if name not in registry:
|
|
130
|
+
return False
|
|
131
|
+
del registry[name]
|
|
132
|
+
save_registry(registry)
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class InstanceInfo:
|
|
138
|
+
"""One registered instance plus its live state."""
|
|
139
|
+
|
|
140
|
+
name: str
|
|
141
|
+
config_path: Path
|
|
142
|
+
repo: Path | None
|
|
143
|
+
daemon_running: bool
|
|
144
|
+
config: ForgeoConfig | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_info(name: str, config_path: Path) -> InstanceInfo:
|
|
148
|
+
"""Build the live state for one registered instance."""
|
|
149
|
+
try:
|
|
150
|
+
config = load_config(config_path)
|
|
151
|
+
except (ValueError, OSError, yaml.YAMLError):
|
|
152
|
+
return InstanceInfo(name, config_path, repo=None, daemon_running=False)
|
|
153
|
+
return InstanceInfo(
|
|
154
|
+
name=name,
|
|
155
|
+
config_path=config_path,
|
|
156
|
+
repo=config.repo,
|
|
157
|
+
daemon_running=is_lock_held(config.backlog.with_suffix(".lock")),
|
|
158
|
+
config=config,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_instance(name: str) -> InstanceInfo | None:
|
|
163
|
+
"""Build the live state for one registered instance, or ``None``.
|
|
164
|
+
|
|
165
|
+
Equivalent to looking up a single entry of :func:`list_instances`; an
|
|
166
|
+
instance whose config can no longer be loaded is still returned, with
|
|
167
|
+
``repo=None``, ``daemon_running=False`` and ``config=None``.
|
|
168
|
+
"""
|
|
169
|
+
config_path = resolve_instance(name)
|
|
170
|
+
if config_path is None:
|
|
171
|
+
return None
|
|
172
|
+
return _load_info(name, config_path)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_instances() -> list[InstanceInfo]:
|
|
176
|
+
"""Return every registered instance, sorted by name.
|
|
177
|
+
|
|
178
|
+
Each entry carries the config path, the configured repository, and
|
|
179
|
+
whether that instance's daemon currently holds its backlog lock. An
|
|
180
|
+
instance whose config can no longer be loaded is still listed, with
|
|
181
|
+
``repo=None`` and ``daemon_running=False``.
|
|
182
|
+
"""
|
|
183
|
+
registry = load_registry()
|
|
184
|
+
return [
|
|
185
|
+
_load_info(name, Path(config_path))
|
|
186
|
+
for name, config_path in sorted(registry.items())
|
|
187
|
+
]
|
forgeo/io.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Small filesystem helpers shared across Forgeo modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def atomic_write_text(path: str | Path, content: str) -> None:
|
|
11
|
+
"""Atomically write ``content`` to ``path`` (temp file + rename).
|
|
12
|
+
|
|
13
|
+
A crash mid-write never leaves a partial file at ``path``: the content is
|
|
14
|
+
first written to a temporary file in the same directory and then moved
|
|
15
|
+
over ``path`` with ``os.replace``. The parent directory is created when
|
|
16
|
+
missing. The temporary file is cleaned up if the write fails.
|
|
17
|
+
"""
|
|
18
|
+
path = Path(path)
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
21
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
22
|
+
)
|
|
23
|
+
try:
|
|
24
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
25
|
+
handle.write(content)
|
|
26
|
+
os.replace(tmp_name, path)
|
|
27
|
+
except BaseException:
|
|
28
|
+
if os.path.exists(tmp_name):
|
|
29
|
+
os.unlink(tmp_name)
|
|
30
|
+
raise
|
forgeo/models.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The only data contracts Forgeo needs.
|
|
2
|
+
|
|
3
|
+
A task lives in the backlog, gets executed by the agent, and changes status
|
|
4
|
+
exactly once per run. A forgeo config describes one repository and how the
|
|
5
|
+
forgeo should work on it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import enum
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
DEFAULT_REFACTOR_PROMPT = (
|
|
17
|
+
"Review the codebase for improvement opportunities that do not change "
|
|
18
|
+
"behavior: dead code, duplication, overly complex functions, missing "
|
|
19
|
+
"tests, outdated comments. Apply the safe improvements you find and run "
|
|
20
|
+
"the test suite to verify nothing broke. If nothing needs refactoring, "
|
|
21
|
+
"make no changes."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_agent_command(value: str | list[str] | None) -> str | list[str] | None:
|
|
26
|
+
"""Shared validation: an agent command must be a non-blank string or list."""
|
|
27
|
+
if value is None:
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, str) and not value.strip():
|
|
30
|
+
raise ValueError("agent_command must not be blank")
|
|
31
|
+
if isinstance(value, list) and not value:
|
|
32
|
+
raise ValueError("agent_command must not be an empty list")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _utcnow() -> datetime:
|
|
37
|
+
return datetime.now(UTC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskStatus(str, enum.Enum):
|
|
41
|
+
OPEN = "OPEN"
|
|
42
|
+
BLOCKED = "BLOCKED"
|
|
43
|
+
COMPLETED = "COMPLETED"
|
|
44
|
+
FAILED = "FAILED"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExecutionStatus(str, enum.Enum):
|
|
48
|
+
SUCCESS = "SUCCESS"
|
|
49
|
+
BLOCKED = "BLOCKED"
|
|
50
|
+
ERROR = "ERROR"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SandboxMode(str, enum.Enum):
|
|
54
|
+
"""How the agent process is isolated from the host machine.
|
|
55
|
+
|
|
56
|
+
``NONE`` runs the agent directly on the host with the user's full
|
|
57
|
+
privileges (the default, unchanged behavior); ``DOCKER`` runs the agent
|
|
58
|
+
inside a ``docker run --rm`` container.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
NONE = "none"
|
|
62
|
+
DOCKER = "docker"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RunKind(str, enum.Enum):
|
|
66
|
+
"""What kind of work a finished cycle performed."""
|
|
67
|
+
|
|
68
|
+
TASK = "task"
|
|
69
|
+
REFACTOR = "refactor"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RunOutcome(str, enum.Enum):
|
|
73
|
+
"""The outcome of a finished cycle.
|
|
74
|
+
|
|
75
|
+
``SUCCESS``, ``BLOCKED`` and ``ERROR`` mirror the agent execution status;
|
|
76
|
+
the remaining values cover cycles that paused or never ran the agent.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
SUCCESS = "SUCCESS"
|
|
80
|
+
BLOCKED = "BLOCKED"
|
|
81
|
+
ERROR = "ERROR"
|
|
82
|
+
PAUSED = "PAUSED"
|
|
83
|
+
DIRTY = "DIRTY"
|
|
84
|
+
SKIPPED = "SKIPPED"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RunRecord(BaseModel):
|
|
88
|
+
"""A durable, queryable record of one finished forgeo cycle.
|
|
89
|
+
|
|
90
|
+
One JSON object per line in ``runs.jsonl``, next to the backlog.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
started_at: datetime
|
|
94
|
+
finished_at: datetime
|
|
95
|
+
kind: RunKind | None = None
|
|
96
|
+
task_id: str | None = None
|
|
97
|
+
task_title: str | None = None
|
|
98
|
+
outcome: RunOutcome
|
|
99
|
+
agent_exit_code: int | None = None
|
|
100
|
+
commit_sha: str | None = None
|
|
101
|
+
duration_seconds: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Task(BaseModel):
|
|
105
|
+
"""A unit of work Forgeo executes with the coding agent."""
|
|
106
|
+
|
|
107
|
+
id: str
|
|
108
|
+
title: str
|
|
109
|
+
description: str
|
|
110
|
+
dependencies: list[str] = Field(default_factory=list)
|
|
111
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
112
|
+
files_to_modify: list[str] = Field(default_factory=list)
|
|
113
|
+
status: TaskStatus = TaskStatus.OPEN
|
|
114
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
115
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
116
|
+
agent_command: str | list[str] | None = Field(default=None)
|
|
117
|
+
agent_timeout_seconds: float | None = Field(default=None, gt=0)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def instruction(self) -> str:
|
|
121
|
+
"""The full instruction handed to the agent for this task."""
|
|
122
|
+
lines = [self.title, ""]
|
|
123
|
+
if self.description:
|
|
124
|
+
lines.append(self.description)
|
|
125
|
+
if self.acceptance_criteria:
|
|
126
|
+
lines.append("Acceptance criteria:")
|
|
127
|
+
lines.extend(f"- {criterion}" for criterion in self.acceptance_criteria)
|
|
128
|
+
return "\n".join(lines)
|
|
129
|
+
|
|
130
|
+
@field_validator("description")
|
|
131
|
+
@classmethod
|
|
132
|
+
def _description_not_blank(cls, value: str) -> str:
|
|
133
|
+
if not value.strip():
|
|
134
|
+
raise ValueError("description must be a non-blank string")
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
@field_validator("agent_command")
|
|
138
|
+
@classmethod
|
|
139
|
+
def _command_not_blank(cls, value: str | list[str] | None) -> str | list[str] | None:
|
|
140
|
+
return _validate_agent_command(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ExecutionResult(BaseModel):
|
|
144
|
+
"""The outcome of one agent run."""
|
|
145
|
+
|
|
146
|
+
status: ExecutionStatus
|
|
147
|
+
output_logs: list[str] = Field(default_factory=list)
|
|
148
|
+
questions: list[str] = Field(default_factory=list)
|
|
149
|
+
error: str | None = None
|
|
150
|
+
exit_code: int | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class RepoContext(BaseModel):
|
|
154
|
+
"""Where the agent works: the repository checkout and its branch."""
|
|
155
|
+
|
|
156
|
+
repo_path: Path = Path(".")
|
|
157
|
+
branch: str = "main"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ForgeoConfig(BaseModel):
|
|
161
|
+
"""Everything needed to run one forgeo on one repository.
|
|
162
|
+
|
|
163
|
+
Attributes:
|
|
164
|
+
name: Display name of this forgeo (used in logs and commit messages).
|
|
165
|
+
repo: Path of the git repository Forgeo works on.
|
|
166
|
+
interval_minutes: How often a scheduled run happens.
|
|
167
|
+
backlog: Path of the JSON backlog file (created on first use).
|
|
168
|
+
blocker_file: Where ``BLOCKER.md`` is written when the agent needs
|
|
169
|
+
human input. Keep it outside the repository so it is never
|
|
170
|
+
committed.
|
|
171
|
+
agent_command: Shell command (or argv list) that runs the coding
|
|
172
|
+
agent. Exit 0 = success, ``blocked_exit_code`` = needs human
|
|
173
|
+
input, anything else = error. The task is available to the
|
|
174
|
+
process as the ``FORGEO_TASK`` environment variable.
|
|
175
|
+
agent_timeout_seconds: Kill the agent process after this many seconds
|
|
176
|
+
(``None`` = never; a run that overruns the interval simply makes
|
|
177
|
+
the next iteration skip).
|
|
178
|
+
agent_env: Extra environment variables for the agent process.
|
|
179
|
+
agent_sandbox: Isolation mode for the agent process: ``none`` (the
|
|
180
|
+
default, runs directly on the host) or ``docker`` (runs inside a
|
|
181
|
+
container). See the README for the docker image expectations.
|
|
182
|
+
agent_sandbox_image: Container image used when ``agent_sandbox`` is
|
|
183
|
+
``docker``. Required in that mode; it must contain the agent CLI
|
|
184
|
+
and a POSIX shell.
|
|
185
|
+
agent_sandbox_network: Docker network for the sandboxed agent
|
|
186
|
+
(``--network``). Default ``none`` (networking disabled); set to
|
|
187
|
+
e.g. ``bridge`` or ``host`` to re-enable it.
|
|
188
|
+
agent_sandbox_mounts: Host paths mounted read-only into the sandboxed
|
|
189
|
+
container at the same absolute path (agent credentials/config).
|
|
190
|
+
Nothing is mounted unless listed here.
|
|
191
|
+
blocked_exit_code: Exit code the agent uses to signal that it needs
|
|
192
|
+
human input.
|
|
193
|
+
remote: Git remote to push to (e.g. ``origin``). When omitted the
|
|
194
|
+
forgeo only commits locally.
|
|
195
|
+
branch: Branch everything is committed to (default ``main``).
|
|
196
|
+
git_timeout_seconds: Kill a git subprocess after this many seconds
|
|
197
|
+
(default 120). Raise for slow remotes.
|
|
198
|
+
refactor_prompt: Instruction used for the refactoring run that
|
|
199
|
+
happens when the backlog has no runnable task.
|
|
200
|
+
log_file: Where the scheduled forgeo writes its log.
|
|
201
|
+
telegram_bot_token: Telegram bot token for blocked-run
|
|
202
|
+
notifications. Disabled unless ``telegram_chat_id`` is also set.
|
|
203
|
+
telegram_chat_id: Chat ID that receives blocked-run notifications.
|
|
204
|
+
Disabled unless ``telegram_bot_token`` is also set.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
name: str = "forgeo"
|
|
208
|
+
repo: Path = Field(default=Path("."))
|
|
209
|
+
interval_minutes: int = Field(default=60, ge=1)
|
|
210
|
+
backlog: Path = Field(default=Path("backlog.json"))
|
|
211
|
+
blocker_file: Path = Field(default=Path("BLOCKER.md"))
|
|
212
|
+
agent_command: str | list[str]
|
|
213
|
+
agent_timeout_seconds: float | None = Field(default=None, gt=0)
|
|
214
|
+
agent_env: dict[str, str] = Field(default_factory=dict)
|
|
215
|
+
agent_sandbox: SandboxMode = SandboxMode.NONE
|
|
216
|
+
agent_sandbox_image: str | None = None
|
|
217
|
+
agent_sandbox_network: str = "none"
|
|
218
|
+
agent_sandbox_mounts: list[str] = Field(default_factory=list)
|
|
219
|
+
blocked_exit_code: int = Field(default=2)
|
|
220
|
+
remote: str | None = None
|
|
221
|
+
branch: str = "main"
|
|
222
|
+
git_timeout_seconds: float = Field(default=120, gt=0)
|
|
223
|
+
refactor_prompt: str = DEFAULT_REFACTOR_PROMPT
|
|
224
|
+
log_file: str = "forgeo.log"
|
|
225
|
+
telegram_bot_token: str | None = None
|
|
226
|
+
telegram_chat_id: str | None = None
|
|
227
|
+
|
|
228
|
+
@field_validator("agent_command")
|
|
229
|
+
@classmethod
|
|
230
|
+
def _command_not_blank(cls, value: str | list[str]) -> str | list[str] | None:
|
|
231
|
+
return _validate_agent_command(value)
|
|
232
|
+
|
|
233
|
+
@field_validator("agent_sandbox_network")
|
|
234
|
+
@classmethod
|
|
235
|
+
def _network_not_blank(cls, value: str) -> str:
|
|
236
|
+
if not value.strip():
|
|
237
|
+
raise ValueError("agent_sandbox_network must not be blank")
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
@field_validator("agent_sandbox_mounts")
|
|
241
|
+
@classmethod
|
|
242
|
+
def _mounts_not_blank(cls, value: list[str]) -> list[str]:
|
|
243
|
+
for mount in value:
|
|
244
|
+
if not mount.strip():
|
|
245
|
+
raise ValueError("agent_sandbox_mounts must not contain blank paths")
|
|
246
|
+
return value
|
|
247
|
+
|
|
248
|
+
@model_validator(mode="after")
|
|
249
|
+
def _docker_requires_image(self) -> ForgeoConfig:
|
|
250
|
+
if self.agent_sandbox is SandboxMode.DOCKER and not (self.agent_sandbox_image or "").strip():
|
|
251
|
+
raise ValueError("agent_sandbox_image is required when agent_sandbox is 'docker'")
|
|
252
|
+
return self
|
forgeo/notify.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Optional Telegram notifications for blocked runs.
|
|
2
|
+
|
|
3
|
+
The feature is disabled unless both ``telegram_bot_token`` and
|
|
4
|
+
``telegram_chat_id`` are set in Forgeo config. Uses only the standard
|
|
5
|
+
library and never raises: a failing notification is logged as a warning and
|
|
6
|
+
the outcome of Forgeo cycle is left unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from forgeo.models import ForgeoConfig
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
SEND_MESSAGE_URL = "https://api.telegram.org/bot{token}/sendMessage"
|
|
21
|
+
REQUEST_TIMEOUT = 5.0
|
|
22
|
+
REASON_LINES = 8
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class BlockedNotice:
|
|
27
|
+
"""The payload of one blocked-run notification."""
|
|
28
|
+
|
|
29
|
+
task_id: str
|
|
30
|
+
task_title: str
|
|
31
|
+
reason: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def blocked_notice_text(forgeo_name: str, notice: BlockedNotice) -> str:
|
|
35
|
+
"""Compose the message body: forgeo name, task id/title, and the reason."""
|
|
36
|
+
lines = [
|
|
37
|
+
f"\u26d4 {forgeo_name} is blocked",
|
|
38
|
+
f"Task {notice.task_id}: {notice.task_title}",
|
|
39
|
+
"",
|
|
40
|
+
*notice.reason.splitlines()[:REASON_LINES],
|
|
41
|
+
]
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def send_blocked_notice(config: ForgeoConfig, notice: BlockedNotice) -> bool:
|
|
46
|
+
"""Send one ``sendMessage`` request; returns True when delivered.
|
|
47
|
+
|
|
48
|
+
Returns ``False`` without a warning when the feature is not configured
|
|
49
|
+
(no notification is expected). Returns ``False`` and logs a warning when
|
|
50
|
+
Telegram rejects or is unreachable — a notification failure never changes
|
|
51
|
+
the outcome of Forgeo cycle.
|
|
52
|
+
"""
|
|
53
|
+
if not config.telegram_bot_token or not config.telegram_chat_id:
|
|
54
|
+
return False
|
|
55
|
+
payload = {
|
|
56
|
+
"chat_id": config.telegram_chat_id,
|
|
57
|
+
"text": blocked_notice_text(config.name, notice),
|
|
58
|
+
}
|
|
59
|
+
url = SEND_MESSAGE_URL.format(token=config.telegram_bot_token)
|
|
60
|
+
request = urllib.request.Request(
|
|
61
|
+
url,
|
|
62
|
+
data=urllib.parse.urlencode(payload).encode("utf-8"),
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
66
|
+
if response.status != 200:
|
|
67
|
+
logger.warning(
|
|
68
|
+
"Telegram notification failed: HTTP %s from %s.",
|
|
69
|
+
response.status,
|
|
70
|
+
url,
|
|
71
|
+
)
|
|
72
|
+
return False
|
|
73
|
+
except (OSError, ValueError) as exc:
|
|
74
|
+
logger.warning("Telegram notification failed: %s", exc)
|
|
75
|
+
return False
|
|
76
|
+
logger.info("Telegram notification sent for blocked run of task %s.", notice.task_id)
|
|
77
|
+
return True
|