agentic-dev-loop 0.10.0__py3-none-any.whl → 0.10.2__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.
- agentic_dev_loop/core/config.py +19 -0
- agentic_dev_loop/core/profiles/__init__.py +178 -0
- agentic_dev_loop/core/profiles/data/dotnet-node.yaml +22 -0
- agentic_dev_loop/core/profiles/data/python.yaml +8 -0
- agentic_dev_loop/engine/cli.py +6 -0
- agentic_dev_loop/engine/hostboundary.py +3 -0
- agentic_dev_loop/engine/onboarding/__init__.py +6 -0
- agentic_dev_loop/engine/onboarding/checks.py +272 -0
- agentic_dev_loop/engine/onboarding/doctor.py +84 -0
- agentic_dev_loop/engine/onboarding/init.py +432 -0
- agentic_dev_loop/engine/onboarding/recipes/dotnet-node.yaml +85 -0
- agentic_dev_loop/engine/onboarding/recipes/python.yaml +57 -0
- agentic_dev_loop/engine/onboarding/recipes.py +77 -0
- agentic_dev_loop/engine/onboarding/templates/dev.agent.md +133 -0
- agentic_dev_loop/engine/onboarding/templates/env.example +11 -0
- agentic_dev_loop/engine/onboarding/templates/qa.agent.md +181 -0
- agentic_dev_loop/engine/onboarding/templates/review.agent.md +53 -0
- agentic_dev_loop/engine/onboarding/templates/trigger-agentic-dev-loop.sh +97 -0
- agentic_dev_loop/engine/onboarding/templates.py +62 -0
- {agentic_dev_loop-0.10.0.dist-info → agentic_dev_loop-0.10.2.dist-info}/METADATA +40 -13
- {agentic_dev_loop-0.10.0.dist-info → agentic_dev_loop-0.10.2.dist-info}/RECORD +25 -9
- {agentic_dev_loop-0.10.0.dist-info → agentic_dev_loop-0.10.2.dist-info}/WHEEL +0 -0
- {agentic_dev_loop-0.10.0.dist-info → agentic_dev_loop-0.10.2.dist-info}/entry_points.txt +0 -0
- {agentic_dev_loop-0.10.0.dist-info → agentic_dev_loop-0.10.2.dist-info}/licenses/LICENSE +0 -0
- {agentic_dev_loop-0.10.0.dist-info → agentic_dev_loop-0.10.2.dist-info}/top_level.txt +0 -0
agentic_dev_loop/core/config.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import importlib.metadata
|
|
6
7
|
import re
|
|
7
8
|
import warnings
|
|
8
9
|
from pathlib import Path
|
|
@@ -647,6 +648,9 @@ class PipelineConfig(BaseModel):
|
|
|
647
648
|
# edit instead of raising. Same declared-path vocabulary as
|
|
648
649
|
# ``sync_excludes:``.
|
|
649
650
|
merge_on_conflict: list[str] = []
|
|
651
|
+
# 055: the toolchain profile resolved at load time. Kept on the validated
|
|
652
|
+
# config so the run records which profile supplied its defaults.
|
|
653
|
+
profile: str | None = None
|
|
650
654
|
|
|
651
655
|
@field_validator("deps_policy")
|
|
652
656
|
@classmethod
|
|
@@ -892,6 +896,21 @@ def load_config(path: Path) -> PipelineConfig:
|
|
|
892
896
|
raise ValueError(
|
|
893
897
|
f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
|
|
894
898
|
)
|
|
899
|
+
profile_ref = data.pop("profile", None)
|
|
900
|
+
if profile_ref is not None:
|
|
901
|
+
from agentic_dev_loop.core.profiles import (
|
|
902
|
+
ProfileError,
|
|
903
|
+
load_profile,
|
|
904
|
+
merge_profile,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
installed = importlib.metadata.version("agentic-dev-loop")
|
|
909
|
+
profile = load_profile(str(profile_ref), installed)
|
|
910
|
+
data = merge_profile(data, profile)
|
|
911
|
+
except ProfileError as exc:
|
|
912
|
+
raise ValueError(f"pipeline {path}: {exc}") from exc
|
|
913
|
+
data["profile"] = profile_ref
|
|
895
914
|
if "state_dir" not in data:
|
|
896
915
|
data["state_dir"] = default_state_dir(path)
|
|
897
916
|
cfg = PipelineConfig.model_validate(data)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Toolchain profiles: bundled data plus the load-time merge into a pipeline.
|
|
2
|
+
|
|
3
|
+
A profile supplies the environment facts every app on a toolchain shares
|
|
4
|
+
(isolation image, caches, premise files, gitignore lines, doctor probes).
|
|
5
|
+
Profiles never carry commands, gates, or steps - those live in
|
|
6
|
+
engine-resident recipes. The subpackage is intentionally dependency-light
|
|
7
|
+
(stdlib + pydantic + PyYAML) so ``core`` can resolve a profile at config
|
|
8
|
+
load time without importing the engine.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PremiseFile",
|
|
22
|
+
"Profile",
|
|
23
|
+
"ProfileError",
|
|
24
|
+
"ProfileIsolation",
|
|
25
|
+
"bundled_profile_names",
|
|
26
|
+
"check_engine_min",
|
|
27
|
+
"load_profile",
|
|
28
|
+
"merge_profile",
|
|
29
|
+
"parse_version",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?$")
|
|
33
|
+
_RANK = {"a": 0, "b": 1, "rc": 2}
|
|
34
|
+
_FINAL = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProfileError(ValueError):
|
|
38
|
+
"""Any profile load, parse, or compatibility failure."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PremiseFile(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
path: str
|
|
45
|
+
content: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProfileIsolation(BaseModel):
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
image: str
|
|
52
|
+
seccomp_profile: str | None = None
|
|
53
|
+
cpus: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Profile(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
engine_min: str
|
|
61
|
+
description: str = ""
|
|
62
|
+
isolation: ProfileIsolation
|
|
63
|
+
caches: list[str] = Field(default_factory=list)
|
|
64
|
+
premise_files: list[PremiseFile] = Field(default_factory=list)
|
|
65
|
+
gitignore_additions: list[str] = Field(default_factory=list)
|
|
66
|
+
doctor_checks: list = Field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_version(text: str) -> tuple[int, int, int, int, int]:
|
|
70
|
+
"""Parse ``X.Y.Z[a|b|rcN]`` into a directly comparable 5-tuple.
|
|
71
|
+
|
|
72
|
+
Rank ordering is alpha < beta < rc < final; the prerelease number is 0
|
|
73
|
+
for a final release so ``1.0.0rc9 < 1.0.0``.
|
|
74
|
+
"""
|
|
75
|
+
match = _VERSION_RE.match(text.strip())
|
|
76
|
+
if match is None:
|
|
77
|
+
raise ProfileError(f"unparseable version: {text!r}")
|
|
78
|
+
major, minor, patch, pre, num = match.groups()
|
|
79
|
+
if pre is None:
|
|
80
|
+
return (int(major), int(minor), int(patch), _FINAL, 0)
|
|
81
|
+
return (int(major), int(minor), int(patch), _RANK[pre], int(num))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_engine_min(engine_min: str, installed: str,
|
|
85
|
+
latest: str | None = None) -> None:
|
|
86
|
+
"""Raise unless ``installed`` satisfies ``engine_min``.
|
|
87
|
+
|
|
88
|
+
``latest`` is substituted into the fix line by the caller (doctor); when
|
|
89
|
+
omitted the floor itself is used.
|
|
90
|
+
"""
|
|
91
|
+
if parse_version(installed) < parse_version(engine_min):
|
|
92
|
+
target = latest or engine_min
|
|
93
|
+
raise ProfileError(
|
|
94
|
+
f"profile requires engine >= {engine_min}; installed {installed}. "
|
|
95
|
+
f'Upgrade: pipx install --force "agentic-dev-loop=={target}"')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _data_dir():
|
|
99
|
+
return resources.files(__package__).joinpath("data")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def bundled_profile_names() -> list[str]:
|
|
103
|
+
return sorted(
|
|
104
|
+
entry.name[: -len(".yaml")]
|
|
105
|
+
for entry in _data_dir().iterdir()
|
|
106
|
+
if entry.name.endswith(".yaml")
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_yaml(text: str, source: str) -> dict:
|
|
111
|
+
try:
|
|
112
|
+
data = yaml.safe_load(text)
|
|
113
|
+
except yaml.YAMLError as exc:
|
|
114
|
+
raise ProfileError(f"profile {source!r}: invalid YAML: {exc}") from exc
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
raise ProfileError(f"profile {source!r} must be a mapping")
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate(data: dict, source: str) -> Profile:
|
|
121
|
+
try:
|
|
122
|
+
return Profile.model_validate(data)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
raise ProfileError(f"profile {source!r}: {exc}") from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_profile(reference: str, installed_version: str) -> Profile:
|
|
128
|
+
"""Load a bundled profile by name or a consumer profile by path.
|
|
129
|
+
|
|
130
|
+
Existence decides: a filesystem path that exists is read as a consumer
|
|
131
|
+
profile; anything else must name a bundled profile.
|
|
132
|
+
"""
|
|
133
|
+
candidate = Path(reference)
|
|
134
|
+
if candidate.is_file():
|
|
135
|
+
data = _read_yaml(candidate.read_text(encoding="utf-8"), reference)
|
|
136
|
+
profile = _validate(data, reference)
|
|
137
|
+
else:
|
|
138
|
+
names = bundled_profile_names()
|
|
139
|
+
if reference not in names:
|
|
140
|
+
raise ProfileError(
|
|
141
|
+
f"unknown profile '{reference}'; bundled profiles: "
|
|
142
|
+
+ ", ".join(names))
|
|
143
|
+
entry = _data_dir().joinpath(f"{reference}.yaml")
|
|
144
|
+
data = _read_yaml(entry.read_text(encoding="utf-8"), reference)
|
|
145
|
+
if data.get("name") != reference:
|
|
146
|
+
raise ProfileError(
|
|
147
|
+
f"profile {reference!r}: declared name {data.get('name')!r} "
|
|
148
|
+
f"does not match its filename")
|
|
149
|
+
profile = _validate(data, reference)
|
|
150
|
+
check_engine_min(profile.engine_min, installed_version)
|
|
151
|
+
return profile
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dedupe(items: list[str]) -> list[str]:
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
out: list[str] = []
|
|
157
|
+
for item in items:
|
|
158
|
+
if item not in seen:
|
|
159
|
+
seen.add(item)
|
|
160
|
+
out.append(item)
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def merge_profile(data: dict, profile: Profile) -> dict:
|
|
165
|
+
"""Merge a profile into a pipeline dict; the pipeline wins.
|
|
166
|
+
|
|
167
|
+
Isolation merges key-wise (profile under, pipeline over). Caches union
|
|
168
|
+
profile-first, dedupe-preserving-order. Every other key is untouched.
|
|
169
|
+
"""
|
|
170
|
+
merged = dict(data)
|
|
171
|
+
profile_iso = profile.isolation.model_dump(exclude_none=True)
|
|
172
|
+
pipeline_iso = merged.get("isolation") or {}
|
|
173
|
+
if not isinstance(pipeline_iso, dict):
|
|
174
|
+
pipeline_iso = {}
|
|
175
|
+
merged["isolation"] = {**profile_iso, **pipeline_iso}
|
|
176
|
+
caches = _dedupe(list(profile.caches) + list(merged.get("caches") or []))
|
|
177
|
+
merged["caches"] = caches
|
|
178
|
+
return merged
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: dotnet-node
|
|
2
|
+
engine_min: 0.9.2
|
|
3
|
+
description: .NET backend with a Node.js frontend
|
|
4
|
+
isolation:
|
|
5
|
+
image: ghcr.io/lukedawilson/agentic-dev-loop/dev-dotnet@sha256:d15d1c6115fafc3d5f3bfb567338d61441081e5d7ff83d7770bc0b5f14f977fc
|
|
6
|
+
seccomp_profile: bundled:chromium-userns
|
|
7
|
+
cpus: 6
|
|
8
|
+
caches:
|
|
9
|
+
- .nuget-packages
|
|
10
|
+
premise_files:
|
|
11
|
+
- path: NuGet.Config
|
|
12
|
+
content: |
|
|
13
|
+
<configuration>
|
|
14
|
+
<config>
|
|
15
|
+
<add key="globalPackagesFolder" value=".nuget-packages" />
|
|
16
|
+
</config>
|
|
17
|
+
</configuration>
|
|
18
|
+
gitignore_additions:
|
|
19
|
+
- bin/
|
|
20
|
+
- obj/
|
|
21
|
+
- node_modules/
|
|
22
|
+
- .nuget-packages/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
name: python
|
|
2
|
+
engine_min: 0.9.2
|
|
3
|
+
description: Python project; the sandbox builds its own venv per run
|
|
4
|
+
isolation:
|
|
5
|
+
image: ghcr.io/lukedawilson/agentic-dev-loop/dev@sha256:890105209c127446caf20ce3dc05f0c0f5527e9d679e18cc0075e6b0ee917434
|
|
6
|
+
cpus: 2
|
|
7
|
+
gitignore_additions:
|
|
8
|
+
- .venv-sandbox/
|
agentic_dev_loop/engine/cli.py
CHANGED
|
@@ -165,6 +165,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
165
165
|
argv = list(argv)
|
|
166
166
|
if argv and argv[0] == "containers":
|
|
167
167
|
return _run_containers(argv[1:])
|
|
168
|
+
if argv and argv[0] == "init":
|
|
169
|
+
from .onboarding.init import run_init
|
|
170
|
+
return run_init(argv[1:])
|
|
171
|
+
if argv and argv[0] == "doctor":
|
|
172
|
+
from .onboarding.doctor import run_doctor
|
|
173
|
+
return run_doctor(argv[1:])
|
|
168
174
|
probe = argparse.ArgumentParser(add_help=False)
|
|
169
175
|
probe.add_argument("pipeline", type=Path, nargs="?")
|
|
170
176
|
_add_version_flag(probe)
|
|
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
|
|
32
32
|
# the dogfood pipeline and the consumer repo use); the other tooling paths are config- or
|
|
33
33
|
# pipeline-file-relative.
|
|
34
34
|
TOOLING_DIR_NAMES = ("aidlc-docs", "tools")
|
|
35
|
+
TOOLING_FILE_NAMES = ("trigger-agentic-dev-loop.sh",
|
|
36
|
+
"agentic-dev-loop/.engine-version")
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
class HostBoundaryError(RuntimeError):
|
|
@@ -73,6 +75,7 @@ def tooling_rel_paths(cfg: "PipelineConfig", pipeline_path: str | Path,
|
|
|
73
75
|
(base / cfg.agents_dir).resolve(),
|
|
74
76
|
*((base / rel).resolve() for rel in cfg.additional_files),
|
|
75
77
|
*(ws / name for name in TOOLING_DIR_NAMES),
|
|
78
|
+
*(ws / name for name in TOOLING_FILE_NAMES),
|
|
76
79
|
]
|
|
77
80
|
rels = {p.relative_to(ws).as_posix() for p in candidates
|
|
78
81
|
if p.is_relative_to(ws)}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Host-side doctor checks and the default probe implementation.
|
|
2
|
+
|
|
3
|
+
Network boundaries (PyPI, image registries, service reachability) are the
|
|
4
|
+
only probes a test may replace; everything else runs real.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib import util as importlib_util
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from agentic_dev_loop.core.profiles import Profile, ProfileError, parse_version
|
|
22
|
+
|
|
23
|
+
__all__ = ["Check", "HostProbes", "collect_checks"]
|
|
24
|
+
|
|
25
|
+
ENV_WHITELIST = frozenset({".env.example", ".env.template", ".env.sample"})
|
|
26
|
+
MIN_API = (1, 43)
|
|
27
|
+
PYPI_URL = "https://pypi.org/pypi/agentic-dev-loop/json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Check:
|
|
32
|
+
name: str
|
|
33
|
+
severity: str
|
|
34
|
+
ok: bool
|
|
35
|
+
message: str = ""
|
|
36
|
+
fix_text: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HostProbes:
|
|
40
|
+
"""Real host-side probes. Tests replace this object, never the checks."""
|
|
41
|
+
|
|
42
|
+
def docker_present(self) -> bool:
|
|
43
|
+
return shutil.which("docker") is not None
|
|
44
|
+
|
|
45
|
+
def daemon_info(self) -> tuple[bool, str, str, str]:
|
|
46
|
+
try:
|
|
47
|
+
api = subprocess.run(
|
|
48
|
+
["docker", "version", "--format", "{{.Server.APIVersion}}"],
|
|
49
|
+
capture_output=True, text=True, check=False)
|
|
50
|
+
info = subprocess.run(
|
|
51
|
+
["docker", "info", "--format", "{{.OSType}}"],
|
|
52
|
+
capture_output=True, text=True, check=False)
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
return (False, "", "", str(exc))
|
|
55
|
+
if api.returncode != 0:
|
|
56
|
+
return (False, "", "", (api.stderr or "daemon unreachable").strip())
|
|
57
|
+
if info.returncode != 0:
|
|
58
|
+
return (False, api.stdout.strip(), "",
|
|
59
|
+
(info.stderr or "daemon unreachable").strip())
|
|
60
|
+
return (True, api.stdout.strip(), info.stdout.strip(), "")
|
|
61
|
+
|
|
62
|
+
def image_ok(self, image: str) -> bool:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["docker", "manifest", "inspect", image],
|
|
65
|
+
capture_output=True, text=True, check=False)
|
|
66
|
+
return result.returncode == 0
|
|
67
|
+
|
|
68
|
+
def git_repo(self, workspace: Path) -> bool:
|
|
69
|
+
return (workspace / ".git").exists()
|
|
70
|
+
|
|
71
|
+
def latest_release(self) -> str | None:
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(PYPI_URL, timeout=10) as response:
|
|
74
|
+
payload = json.load(response)
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
versions = [
|
|
78
|
+
str(candidate) for candidate in (payload.get("releases") or {})
|
|
79
|
+
if _parseable(str(candidate))
|
|
80
|
+
]
|
|
81
|
+
if versions:
|
|
82
|
+
return max(versions, key=parse_version)
|
|
83
|
+
version = (payload.get("info") or {}).get("version")
|
|
84
|
+
return str(version) if version else None
|
|
85
|
+
|
|
86
|
+
def service_reachable(self, host: str, port: int) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
with socket.create_connection((host, port), timeout=2):
|
|
89
|
+
return True
|
|
90
|
+
except OSError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def command_ok(self, command: str) -> bool:
|
|
94
|
+
result = subprocess.run(command, shell=True, capture_output=True,
|
|
95
|
+
text=True, check=False)
|
|
96
|
+
return result.returncode == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _hard(name: str, ok: bool, message: str = "",
|
|
100
|
+
fix_text: str = "") -> Check:
|
|
101
|
+
return Check(name, "hard", ok, message, fix_text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _warn(name: str, ok: bool, message: str = "",
|
|
105
|
+
fix_text: str = "") -> Check:
|
|
106
|
+
return Check(name, "warn", ok, message, fix_text)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parseable(text: str) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
parse_version(text)
|
|
112
|
+
except ProfileError:
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _api_at_least(api: str) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
parts = tuple(int(p) for p in api.split(".")[:2])
|
|
120
|
+
except ValueError:
|
|
121
|
+
return False
|
|
122
|
+
return parts >= MIN_API
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _env_files(workspace: Path) -> list[str]:
|
|
126
|
+
if not workspace.is_dir():
|
|
127
|
+
return []
|
|
128
|
+
return sorted(
|
|
129
|
+
entry.name for entry in workspace.iterdir()
|
|
130
|
+
if entry.name.startswith(".env") and entry.name not in ENV_WHITELIST)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_compose_services(workspace: Path) -> list[tuple[str, int]]:
|
|
134
|
+
for name in ("docker-compose.yml", "docker-compose.yaml"):
|
|
135
|
+
path = workspace / name
|
|
136
|
+
if not path.is_file():
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
140
|
+
except yaml.YAMLError:
|
|
141
|
+
return []
|
|
142
|
+
found: list[tuple[str, int]] = []
|
|
143
|
+
for service, spec in (data.get("services") or {}).items():
|
|
144
|
+
for port in (spec or {}).get("ports") or []:
|
|
145
|
+
text = str(port).split("/")[0]
|
|
146
|
+
host_port = text.split(":")[-1]
|
|
147
|
+
try:
|
|
148
|
+
found.append((str(service), int(host_port)))
|
|
149
|
+
except ValueError:
|
|
150
|
+
continue
|
|
151
|
+
return found
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def collect_checks(profile: Profile | None, pipeline, installed: str,
|
|
156
|
+
fast: bool, *, workspace: Path | None = None,
|
|
157
|
+
probes: HostProbes | None = None) -> list[Check]:
|
|
158
|
+
workspace = Path(workspace or Path.cwd()).resolve()
|
|
159
|
+
probes = probes or HostProbes()
|
|
160
|
+
checks: list[Check] = []
|
|
161
|
+
|
|
162
|
+
checks.append(_hard(
|
|
163
|
+
"engine",
|
|
164
|
+
bool(installed) and importlib_util.find_spec("agentic_dev_loop")
|
|
165
|
+
is not None,
|
|
166
|
+
"engine package importable" if installed else "engine version unreadable"))
|
|
167
|
+
|
|
168
|
+
pin_path = workspace / "agentic-dev-loop" / ".engine-version"
|
|
169
|
+
pin = (pin_path.read_text(encoding="utf-8").strip()
|
|
170
|
+
if pin_path.is_file() else None)
|
|
171
|
+
if pin is None:
|
|
172
|
+
checks.append(_hard(
|
|
173
|
+
"pin", False, f"no engine pin file at {pin_path}",
|
|
174
|
+
"run agentic-dev-loop init (or write agentic-dev-loop/.engine-version)"))
|
|
175
|
+
else:
|
|
176
|
+
checks.append(_hard(
|
|
177
|
+
"pin", pin == installed,
|
|
178
|
+
f"installed {installed}, pin {pin}",
|
|
179
|
+
f'pipx install --force "agentic-dev-loop=={pin}"'))
|
|
180
|
+
latest = probes.latest_release()
|
|
181
|
+
if latest is None:
|
|
182
|
+
checks.append(_hard("pin-latest", True,
|
|
183
|
+
"latest release not reachable; skipped"))
|
|
184
|
+
else:
|
|
185
|
+
try:
|
|
186
|
+
behind = parse_version(pin) < parse_version(latest)
|
|
187
|
+
except ProfileError:
|
|
188
|
+
behind = False
|
|
189
|
+
checks.append(_hard(
|
|
190
|
+
"pin-latest", not behind,
|
|
191
|
+
f"pin {pin} is behind latest release {latest}",
|
|
192
|
+
f'pipx install --force "agentic-dev-loop=={latest}"'))
|
|
193
|
+
|
|
194
|
+
present = probes.docker_present()
|
|
195
|
+
checks.append(_hard(
|
|
196
|
+
"runtime", present,
|
|
197
|
+
"docker is on PATH" if present else "docker is not on PATH",
|
|
198
|
+
"install a compliant container engine (OrbStack, Colima, podman)"))
|
|
199
|
+
ok, api, ostype, error = probes.daemon_info()
|
|
200
|
+
checks.append(_hard("daemon", ok,
|
|
201
|
+
"daemon reachable" if ok else (error or "daemon unreachable"),
|
|
202
|
+
"start the container engine"))
|
|
203
|
+
checks.append(_hard(
|
|
204
|
+
"api-version", ok and _api_at_least(api),
|
|
205
|
+
f"engine API {api}" if ok else "engine API unavailable",
|
|
206
|
+
"upgrade to a compliant engine (API >= 1.43)"))
|
|
207
|
+
checks.append(_hard(
|
|
208
|
+
"ostype", ok and ostype.lower() == "linux",
|
|
209
|
+
f"daemon OSType {ostype}" if ok else "daemon OSType unavailable",
|
|
210
|
+
"run a Linux container engine (or WSL2 on Windows)"))
|
|
211
|
+
|
|
212
|
+
git_ok = probes.git_repo(workspace)
|
|
213
|
+
checks.append(_hard(
|
|
214
|
+
"workspace", git_ok,
|
|
215
|
+
"workspace is a git repo" if git_ok else "workspace is not a git repo",
|
|
216
|
+
"git init the workspace before running the loop"))
|
|
217
|
+
|
|
218
|
+
env_files = _env_files(workspace)
|
|
219
|
+
checks.append(_hard(
|
|
220
|
+
"env-files", not env_files,
|
|
221
|
+
"no .env* files" if not env_files
|
|
222
|
+
else "refused .env* files: " + ", ".join(env_files),
|
|
223
|
+
"move secrets to the host environment"))
|
|
224
|
+
|
|
225
|
+
if not fast and pipeline is not None and pipeline.isolation is not None:
|
|
226
|
+
image = pipeline.isolation.image
|
|
227
|
+
if image:
|
|
228
|
+
checks.append(_hard(
|
|
229
|
+
"image", probes.image_ok(image),
|
|
230
|
+
f"image pullable: {image}",
|
|
231
|
+
"publish the image or fix isolation.image"))
|
|
232
|
+
|
|
233
|
+
if profile is not None:
|
|
234
|
+
for entry in profile.doctor_checks:
|
|
235
|
+
if not isinstance(entry, dict):
|
|
236
|
+
continue
|
|
237
|
+
name = str(entry.get("name", "profile-check"))
|
|
238
|
+
command = entry.get("command")
|
|
239
|
+
if command is None:
|
|
240
|
+
continue
|
|
241
|
+
checks.append(_warn(
|
|
242
|
+
f"profile:{name}", probes.command_ok(str(command)),
|
|
243
|
+
f"profile probe failed: {command}",
|
|
244
|
+
"install the missing toolchain"))
|
|
245
|
+
for premise in profile.premise_files:
|
|
246
|
+
exists = (workspace / premise.path).exists()
|
|
247
|
+
checks.append(_warn(
|
|
248
|
+
f"premise:{premise.path}", exists,
|
|
249
|
+
f"missing premise file: {premise.path}",
|
|
250
|
+
"re-run agentic-dev-loop init or restore the file"))
|
|
251
|
+
|
|
252
|
+
if pipeline is not None:
|
|
253
|
+
key_env = pipeline.llm.api_key_env
|
|
254
|
+
checks.append(_warn(
|
|
255
|
+
f"llm-key:{key_env}", bool(os.environ.get(key_env)),
|
|
256
|
+
f"LLM key env var not set: {key_env}",
|
|
257
|
+
f"export {key_env}=..."))
|
|
258
|
+
for ref, spec in pipeline.secrets.items():
|
|
259
|
+
checks.append(_warn(
|
|
260
|
+
f"secret:{ref}", bool(os.environ.get(spec.key)),
|
|
261
|
+
f"secret env var not set: {spec.key}",
|
|
262
|
+
f"export {spec.key}=..."))
|
|
263
|
+
|
|
264
|
+
if not fast:
|
|
265
|
+
for service, port in _read_compose_services(workspace):
|
|
266
|
+
reachable = probes.service_reachable("127.0.0.1", port)
|
|
267
|
+
checks.append(_warn(
|
|
268
|
+
f"service:{service}", reachable,
|
|
269
|
+
f"compose service {service} unreachable on port {port}",
|
|
270
|
+
"start the service before running the loop"))
|
|
271
|
+
|
|
272
|
+
return checks
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""``agentic-dev-loop doctor``: host-side onboarding gate.
|
|
2
|
+
|
|
3
|
+
Prints one actionable line per non-pass check, hard failures first. Exit 1
|
|
4
|
+
iff at least one hard check fails; warnings never fail the run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from agentic_dev_loop.core.config import load_config
|
|
16
|
+
from agentic_dev_loop.core.profiles import ProfileError, load_profile
|
|
17
|
+
|
|
18
|
+
from .checks import Check, HostProbes, collect_checks
|
|
19
|
+
|
|
20
|
+
__all__ = ["run_doctor"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _installed_version() -> str | None:
|
|
24
|
+
try:
|
|
25
|
+
return importlib.metadata.version("agentic-dev-loop")
|
|
26
|
+
except importlib.metadata.PackageNotFoundError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_failures(checks: list[Check]) -> None:
|
|
31
|
+
failures = [check for check in checks if not check.ok]
|
|
32
|
+
for severity in ("hard", "warn"):
|
|
33
|
+
for check in failures:
|
|
34
|
+
if check.severity != severity:
|
|
35
|
+
continue
|
|
36
|
+
print(f"{severity.upper()}: {check.name}: {check.message}")
|
|
37
|
+
if check.fix_text:
|
|
38
|
+
print(f" fix: {check.fix_text}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_doctor(argv: list[str], *, workspace: Path | None = None,
|
|
42
|
+
probes: HostProbes | None = None,
|
|
43
|
+
installed: str | None = None) -> int:
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
prog="agentic-dev-loop doctor",
|
|
46
|
+
description="Probe this host and repo for onboarding prerequisites.")
|
|
47
|
+
parser.add_argument("--fast", action="store_true",
|
|
48
|
+
help="skip checks needing more than one quick network "
|
|
49
|
+
"round trip")
|
|
50
|
+
parser.add_argument("--json", action="store_true",
|
|
51
|
+
help="emit machine-readable JSON results")
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
|
|
54
|
+
root = Path(workspace or Path.cwd()).resolve()
|
|
55
|
+
probes = probes or HostProbes()
|
|
56
|
+
if installed is None:
|
|
57
|
+
installed = _installed_version()
|
|
58
|
+
|
|
59
|
+
pipeline = None
|
|
60
|
+
profile = None
|
|
61
|
+
extra: list[Check] = []
|
|
62
|
+
pipeline_path = root / "agentic-dev-loop" / "pipeline.yaml"
|
|
63
|
+
if pipeline_path.is_file():
|
|
64
|
+
try:
|
|
65
|
+
pipeline = load_config(pipeline_path)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
extra.append(Check("pipeline", "hard", False, str(exc),
|
|
68
|
+
"fix the pipeline config"))
|
|
69
|
+
if pipeline is not None and pipeline.profile and installed:
|
|
70
|
+
try:
|
|
71
|
+
profile = load_profile(pipeline.profile, installed)
|
|
72
|
+
except ProfileError as exc:
|
|
73
|
+
extra.append(Check("profile", "hard", False, str(exc),
|
|
74
|
+
"upgrade the engine or fix the profile"))
|
|
75
|
+
|
|
76
|
+
checks = collect_checks(profile, pipeline, installed or "", args.fast,
|
|
77
|
+
workspace=root, probes=probes) + extra
|
|
78
|
+
|
|
79
|
+
if args.json:
|
|
80
|
+
print(json.dumps([asdict(check) for check in checks]))
|
|
81
|
+
else:
|
|
82
|
+
_print_failures(checks)
|
|
83
|
+
return 1 if any(
|
|
84
|
+
check.severity == "hard" and not check.ok for check in checks) else 0
|