codexloop 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.
- codexloop/__init__.py +3 -0
- codexloop/application/__init__.py +1 -0
- codexloop/application/dto.py +41 -0
- codexloop/application/ports.py +158 -0
- codexloop/application/runner.py +467 -0
- codexloop/application/usecases/__init__.py +1 -0
- codexloop/application/usecases/doctor.py +37 -0
- codexloop/application/usecases/list_threads.py +14 -0
- codexloop/application/usecases/preflight.py +10 -0
- codexloop/application/usecases/resume_thread.py +11 -0
- codexloop/application/usecases/run_control.py +20 -0
- codexloop/application/usecases/run_plan.py +11 -0
- codexloop/bootstrap.py +461 -0
- codexloop/cli/__init__.py +1 -0
- codexloop/cli/app.py +94 -0
- codexloop/cli/asyncio.py +80 -0
- codexloop/cli/commands/__init__.py +1 -0
- codexloop/cli/commands/approval_cmd.py +24 -0
- codexloop/cli/commands/capacity.py +33 -0
- codexloop/cli/commands/cwd_cmd.py +22 -0
- codexloop/cli/commands/doctor.py +27 -0
- codexloop/cli/commands/effort_cmd.py +24 -0
- codexloop/cli/commands/logs.py +15 -0
- codexloop/cli/commands/model_cmd.py +22 -0
- codexloop/cli/commands/prompt.py +32 -0
- codexloop/cli/commands/reset.py +25 -0
- codexloop/cli/commands/resume.py +38 -0
- codexloop/cli/commands/run.py +50 -0
- codexloop/cli/commands/runs.py +13 -0
- codexloop/cli/commands/sandbox_cmd.py +24 -0
- codexloop/cli/commands/savepoints.py +25 -0
- codexloop/cli/commands/snapshot.py +26 -0
- codexloop/cli/commands/status.py +15 -0
- codexloop/cli/commands/stop.py +21 -0
- codexloop/cli/commands/threads.py +15 -0
- codexloop/cli/commands/unwind.py +24 -0
- codexloop/cli/commands/watch.py +66 -0
- codexloop/cli/render.py +39 -0
- codexloop/domain/__init__.py +26 -0
- codexloop/domain/approval.py +38 -0
- codexloop/domain/backoff.py +34 -0
- codexloop/domain/budget.py +56 -0
- codexloop/domain/capacity.py +81 -0
- codexloop/domain/classify.py +98 -0
- codexloop/domain/completion.py +130 -0
- codexloop/domain/control.py +167 -0
- codexloop/domain/error_codes.py +75 -0
- codexloop/domain/errors.py +35 -0
- codexloop/domain/loop.py +190 -0
- codexloop/domain/model_profile.py +30 -0
- codexloop/domain/plan.py +38 -0
- codexloop/domain/savepoint.py +32 -0
- codexloop/domain/savepoint_message.py +56 -0
- codexloop/domain/session.py +32 -0
- codexloop/domain/signals.py +25 -0
- codexloop/domain/waiting.py +120 -0
- codexloop/infrastructure/__init__.py +0 -0
- codexloop/infrastructure/agent/__init__.py +0 -0
- codexloop/infrastructure/agent/argv.py +102 -0
- codexloop/infrastructure/agent/events.py +274 -0
- codexloop/infrastructure/agent/gateway.py +189 -0
- codexloop/infrastructure/agent/probe.py +74 -0
- codexloop/infrastructure/agent/process.py +208 -0
- codexloop/infrastructure/agent/schema.py +31 -0
- codexloop/infrastructure/agent/scripted.py +201 -0
- codexloop/infrastructure/agent/translate.py +120 -0
- codexloop/infrastructure/api/__init__.py +26 -0
- codexloop/infrastructure/api/api_baseline.json +340 -0
- codexloop/infrastructure/api/binder.py +170 -0
- codexloop/infrastructure/api/gateway.py +142 -0
- codexloop/infrastructure/api/introspect.py +248 -0
- codexloop/infrastructure/api/json_io.py +26 -0
- codexloop/infrastructure/api/params.py +162 -0
- codexloop/infrastructure/api/providers.py +70 -0
- codexloop/infrastructure/api/registry.py +13 -0
- codexloop/infrastructure/appserver/__init__.py +6 -0
- codexloop/infrastructure/appserver/client.py +245 -0
- codexloop/infrastructure/appserver/gateway.py +437 -0
- codexloop/infrastructure/appserver/ratelimits.py +100 -0
- codexloop/infrastructure/audit.py +26 -0
- codexloop/infrastructure/capacity_probe.py +57 -0
- codexloop/infrastructure/clock.py +26 -0
- codexloop/infrastructure/config.py +150 -0
- codexloop/infrastructure/control.py +89 -0
- codexloop/infrastructure/doctor_env.py +239 -0
- codexloop/infrastructure/events.py +23 -0
- codexloop/infrastructure/git_savepoints.py +176 -0
- codexloop/infrastructure/lock.py +88 -0
- codexloop/infrastructure/logging.py +124 -0
- codexloop/infrastructure/notify.py +27 -0
- codexloop/infrastructure/progress.py +14 -0
- codexloop/infrastructure/redact.py +52 -0
- codexloop/infrastructure/rollout.py +113 -0
- codexloop/infrastructure/rundir.py +57 -0
- codexloop/infrastructure/snapshot.py +39 -0
- codexloop/infrastructure/state.py +32 -0
- codexloop/infrastructure/state_bus.py +27 -0
- codexloop/infrastructure/stream_ui.py +44 -0
- codexloop/py.typed +0 -0
- codexloop-0.1.0.dist-info/METADATA +104 -0
- codexloop-0.1.0.dist-info/RECORD +104 -0
- codexloop-0.1.0.dist-info/WHEEL +4 -0
- codexloop-0.1.0.dist-info/entry_points.txt +2 -0
- codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Map app-server ``account/rateLimits/read`` results onto ``PlanWindows``.
|
|
2
|
+
|
|
3
|
+
Field names match the exec JSONL rate-limit blob already parsed in
|
|
4
|
+
``infrastructure.agent.events`` (``primary`` / ``secondary`` / ``plan_type`` /
|
|
5
|
+
``rate_limit_reached_type``, ``used_percent`` / ``window_minutes`` /
|
|
6
|
+
``resets_at`` / ``resets_in_seconds``). Helpers are duplicated here so this
|
|
7
|
+
package does not depend on agent internals.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from codexloop.domain.capacity import PlanWindows, RateLimitWindow
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def plan_windows_from_rpc(result: object, *, now: datetime) -> PlanWindows | None:
|
|
20
|
+
"""Return ``PlanWindows`` from an RPC ``result`` payload, or ``None``."""
|
|
21
|
+
if not isinstance(result, Mapping):
|
|
22
|
+
return None
|
|
23
|
+
blob = _rate_limits_blob(result)
|
|
24
|
+
if blob is None or not isinstance(blob, Mapping):
|
|
25
|
+
return None
|
|
26
|
+
return PlanWindows(
|
|
27
|
+
primary=_window(blob.get("primary"), now=now),
|
|
28
|
+
secondary=_window(blob.get("secondary"), now=now),
|
|
29
|
+
plan_type=_opt_str(blob.get("plan_type")),
|
|
30
|
+
limit_reached=_opt_str(blob.get("rate_limit_reached_type")),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rate_limits_blob(obj: Mapping[str, Any]) -> object:
|
|
35
|
+
if "rate_limits" in obj:
|
|
36
|
+
return obj["rate_limits"]
|
|
37
|
+
payload = obj.get("payload")
|
|
38
|
+
if isinstance(payload, Mapping) and "rate_limits" in payload:
|
|
39
|
+
return payload["rate_limits"]
|
|
40
|
+
if any(key in obj for key in ("primary", "secondary", "plan_type", "rate_limit_reached_type")):
|
|
41
|
+
return obj
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _window(value: object, *, now: datetime) -> RateLimitWindow | None:
|
|
46
|
+
if not isinstance(value, Mapping):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
minutes = _opt_int(value.get("window_minutes"))
|
|
50
|
+
if minutes is None:
|
|
51
|
+
return None
|
|
52
|
+
return RateLimitWindow(
|
|
53
|
+
used_percent=_opt_float(value.get("used_percent")),
|
|
54
|
+
window_minutes=minutes,
|
|
55
|
+
resets_at=_resets_at(value, now=now),
|
|
56
|
+
)
|
|
57
|
+
except (TypeError, ValueError, OverflowError, OSError): # pragma: no cover
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resets_at(window: Mapping[str, Any], *, now: datetime) -> datetime | None:
|
|
62
|
+
raw_at = window.get("resets_at")
|
|
63
|
+
if isinstance(raw_at, bool):
|
|
64
|
+
raw_at = None
|
|
65
|
+
if isinstance(raw_at, int | float):
|
|
66
|
+
try:
|
|
67
|
+
return datetime.fromtimestamp(float(raw_at), tz=UTC)
|
|
68
|
+
except (OSError, OverflowError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
raw_in = window.get("resets_in_seconds")
|
|
71
|
+
if isinstance(raw_in, bool):
|
|
72
|
+
raw_in = None
|
|
73
|
+
if isinstance(raw_in, int | float):
|
|
74
|
+
try:
|
|
75
|
+
return now + timedelta(seconds=float(raw_in))
|
|
76
|
+
except (OverflowError, ValueError): # pragma: no cover
|
|
77
|
+
return None
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _opt_str(value: object) -> str | None:
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
return value
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _opt_int(value: object) -> int | None:
|
|
88
|
+
if isinstance(value, bool):
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(value, int):
|
|
91
|
+
return value
|
|
92
|
+
if isinstance(value, float) and value.is_integer():
|
|
93
|
+
return int(value)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _opt_float(value: object) -> float | None:
|
|
98
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
99
|
+
return None
|
|
100
|
+
return float(value)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""JSONL audit log — the AuditLog port's filesystem adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codexloop.infrastructure.redact import redact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonlAuditLog:
|
|
14
|
+
def __init__(self, path: Path) -> None:
|
|
15
|
+
self._path = path
|
|
16
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
def append(self, event_type: str, payload: Mapping[str, object]) -> None:
|
|
19
|
+
entry: dict[str, object] = {
|
|
20
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
21
|
+
"event_type": event_type,
|
|
22
|
+
**dict(payload),
|
|
23
|
+
}
|
|
24
|
+
safe = redact(entry)
|
|
25
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
26
|
+
handle.write(json.dumps(safe, default=str) + "\n")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Layered capacity probe: app-server (B), rollout (C), exec (A)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from inspect import isawaitable
|
|
7
|
+
|
|
8
|
+
from codexloop.application.dto import ProbeResult
|
|
9
|
+
from codexloop.application.ports import CapacityProbe
|
|
10
|
+
from codexloop.domain.capacity import PlanWindows
|
|
11
|
+
|
|
12
|
+
AppServerLimits = Callable[[], Awaitable[PlanWindows | None] | PlanWindows | None]
|
|
13
|
+
RolloutLimits = Callable[[], PlanWindows | None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CompositeCapacityProbe:
|
|
17
|
+
"""Exec is always authoritative; B and C only enrich the snapshot."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
exec_probe: CapacityProbe,
|
|
22
|
+
*,
|
|
23
|
+
app_server: AppServerLimits | None = None,
|
|
24
|
+
rollout: RolloutLimits | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._exec = exec_probe
|
|
27
|
+
self._app_server = app_server
|
|
28
|
+
self._rollout = rollout
|
|
29
|
+
|
|
30
|
+
async def probe(self) -> ProbeResult:
|
|
31
|
+
snapshot = await self._read_app_server()
|
|
32
|
+
if snapshot is None:
|
|
33
|
+
snapshot = self._read_rollout()
|
|
34
|
+
exec_result = await self._exec.probe()
|
|
35
|
+
return ProbeResult(outcome=exec_result.outcome, snapshot=snapshot or exec_result.snapshot)
|
|
36
|
+
|
|
37
|
+
async def _read_app_server(self) -> PlanWindows | None:
|
|
38
|
+
if self._app_server is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
result = self._app_server()
|
|
42
|
+
if isawaitable(result):
|
|
43
|
+
return await result
|
|
44
|
+
return result
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def _read_rollout(self) -> PlanWindows | None:
|
|
49
|
+
if self._rollout is None:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return self._rollout()
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["CompositeCapacityProbe"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Real Clock and Sleeper adapters. Test doubles live in tests/application/fakes.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
|
|
9
|
+
from codexloop.application.ports import Clock
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SystemClock:
|
|
13
|
+
def now(self) -> datetime:
|
|
14
|
+
return datetime.now(UTC)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnyioSleeper:
|
|
18
|
+
"""Sleep the remaining delta via ``anyio.sleep``; past targets are a no-op."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, clock: Clock | None = None) -> None:
|
|
21
|
+
self._clock: Clock = clock if clock is not None else SystemClock()
|
|
22
|
+
|
|
23
|
+
async def sleep_until(self, when: datetime) -> None:
|
|
24
|
+
delay = (when - self._clock.now()).total_seconds()
|
|
25
|
+
if delay > 0:
|
|
26
|
+
await anyio.sleep(delay)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Configuration precedence: flags > env > project toml > user toml > defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, fields, replace
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from codexloop.domain.errors import ConfigurationError
|
|
15
|
+
|
|
16
|
+
_ENV_PREFIX = "CODEXLOOP_"
|
|
17
|
+
_DURATION = re.compile(
|
|
18
|
+
r"^(?:(?P<days>\d+)d)?(?:(?P<hours>\d+)h)?(?:(?P<minutes>\d+)m)?"
|
|
19
|
+
r"(?:(?P<seconds>\d+(?:\.\d+)?)s)?$"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class RunnerConfig:
|
|
25
|
+
"""Runtime knobs. ``model=None`` leaves model selection to the Codex CLI."""
|
|
26
|
+
|
|
27
|
+
model: str | None = None
|
|
28
|
+
max_turns: int = 100
|
|
29
|
+
json_logs: bool = False
|
|
30
|
+
max_wait: timedelta = timedelta(hours=24)
|
|
31
|
+
add_dirs: tuple[str, ...] = ()
|
|
32
|
+
log_level: str = "INFO"
|
|
33
|
+
log_file: str | None = None
|
|
34
|
+
notify_command: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_KNOWN = {f.name for f in fields(RunnerConfig)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_bool(value: object) -> bool:
|
|
41
|
+
if isinstance(value, bool):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
lowered = value.strip().lower()
|
|
45
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
46
|
+
return True
|
|
47
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
48
|
+
return False
|
|
49
|
+
raise ConfigurationError(f"invalid bool {value!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _as_int(value: object) -> int:
|
|
53
|
+
if isinstance(value, bool):
|
|
54
|
+
raise ConfigurationError(f"invalid int {value!r}")
|
|
55
|
+
if isinstance(value, int):
|
|
56
|
+
return value
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
return int(value.strip())
|
|
59
|
+
raise ConfigurationError(f"invalid int {value!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _as_duration(value: object) -> timedelta:
|
|
63
|
+
if isinstance(value, timedelta):
|
|
64
|
+
return value
|
|
65
|
+
if isinstance(value, bool):
|
|
66
|
+
raise ConfigurationError(f"invalid duration {value!r}")
|
|
67
|
+
if isinstance(value, int | float):
|
|
68
|
+
return timedelta(seconds=float(value))
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
text = value.strip()
|
|
71
|
+
try:
|
|
72
|
+
return timedelta(seconds=float(text))
|
|
73
|
+
except ValueError:
|
|
74
|
+
pass
|
|
75
|
+
matched = _DURATION.fullmatch(text)
|
|
76
|
+
if matched is not None and any(matched.groups()):
|
|
77
|
+
days = int(matched.group("days") or 0)
|
|
78
|
+
hours = int(matched.group("hours") or 0)
|
|
79
|
+
minutes = int(matched.group("minutes") or 0)
|
|
80
|
+
seconds = float(matched.group("seconds") or 0)
|
|
81
|
+
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
|
|
82
|
+
raise ConfigurationError(f"invalid duration {value!r}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _as_str_tuple(value: object) -> tuple[str, ...]:
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
parts = [part.strip() for part in value.split(",")]
|
|
88
|
+
return tuple(part for part in parts if part)
|
|
89
|
+
if isinstance(value, list | tuple):
|
|
90
|
+
return tuple(str(item) for item in value)
|
|
91
|
+
raise ConfigurationError(f"invalid list {value!r}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _coerce_field(name: str, value: object) -> Any:
|
|
95
|
+
if name in {"model", "log_level"}:
|
|
96
|
+
return str(value)
|
|
97
|
+
if name == "max_turns":
|
|
98
|
+
return _as_int(value)
|
|
99
|
+
if name == "json_logs":
|
|
100
|
+
return _as_bool(value)
|
|
101
|
+
if name == "max_wait":
|
|
102
|
+
return _as_duration(value)
|
|
103
|
+
if name == "add_dirs":
|
|
104
|
+
return _as_str_tuple(value)
|
|
105
|
+
if name in {"log_file", "notify_command"}:
|
|
106
|
+
return None if value is None else str(value)
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _from_file(path: Path) -> dict[str, Any]:
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
return {}
|
|
113
|
+
with path.open("rb") as handle:
|
|
114
|
+
data = tomllib.load(handle)
|
|
115
|
+
return {key: value for key, value in data.items() if key in _KNOWN}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _from_env(environ: Mapping[str, str]) -> dict[str, Any]:
|
|
119
|
+
overrides: dict[str, Any] = {}
|
|
120
|
+
for name in _KNOWN:
|
|
121
|
+
raw = environ.get(_ENV_PREFIX + name.upper())
|
|
122
|
+
if raw is not None:
|
|
123
|
+
overrides[name] = raw
|
|
124
|
+
return overrides
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_config(
|
|
128
|
+
*,
|
|
129
|
+
cwd: Path | None = None,
|
|
130
|
+
home: Path | None = None,
|
|
131
|
+
environ: Mapping[str, str] | None = None,
|
|
132
|
+
flags: Mapping[str, object] | None = None,
|
|
133
|
+
) -> RunnerConfig:
|
|
134
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
135
|
+
home = Path.home() if home is None else home
|
|
136
|
+
env = os.environ if environ is None else environ
|
|
137
|
+
|
|
138
|
+
merged: dict[str, Any] = {}
|
|
139
|
+
merged.update(_from_file(home / ".config" / "codexloop" / "codexloop.toml"))
|
|
140
|
+
merged.update(_from_file(cwd / "codexloop.toml"))
|
|
141
|
+
merged.update(_from_env(env))
|
|
142
|
+
if flags:
|
|
143
|
+
merged.update({key: value for key, value in flags.items() if value is not None})
|
|
144
|
+
|
|
145
|
+
if not merged:
|
|
146
|
+
return RunnerConfig()
|
|
147
|
+
coerced: dict[str, Any] = {
|
|
148
|
+
key: _coerce_field(key, value) for key, value in merged.items() if key in _KNOWN
|
|
149
|
+
}
|
|
150
|
+
return replace(RunnerConfig(), **coerced)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""File-based RunControl — operator commands land in inbox/*.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codexloop.application.ports import Logger, RunControl
|
|
11
|
+
from codexloop.domain.control import ControlCommand, parse_control
|
|
12
|
+
from codexloop.domain.errors import ConfigurationError
|
|
13
|
+
from codexloop.infrastructure.logging import StructlogAppLogger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileRunControl:
|
|
17
|
+
"""Poll inbox JSON files; archive successes; quarantine malformed."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, inbox: Path, *, logger: Logger | None = None) -> None:
|
|
20
|
+
self._inbox = inbox
|
|
21
|
+
self._archive = inbox / "archive"
|
|
22
|
+
self._quarantine = inbox / "quarantine"
|
|
23
|
+
self._logger: Logger = logger if logger is not None else StructlogAppLogger()
|
|
24
|
+
self._inbox.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
self._archive.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self._quarantine.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def enqueue(self, command: ControlCommand) -> Path:
|
|
29
|
+
payload = command.to_dict()
|
|
30
|
+
kind = str(payload.get("kind", "command"))
|
|
31
|
+
name = f"{time.time_ns()}-{kind}.json"
|
|
32
|
+
path = self._inbox / name
|
|
33
|
+
path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
def poll(self) -> Sequence[ControlCommand]:
|
|
37
|
+
files = sorted(
|
|
38
|
+
path
|
|
39
|
+
for path in self._inbox.iterdir()
|
|
40
|
+
if path.is_file() and path.suffix == ".json" and path.name.endswith(".json")
|
|
41
|
+
)
|
|
42
|
+
commands: list[ControlCommand] = []
|
|
43
|
+
for path in files:
|
|
44
|
+
try:
|
|
45
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
if not isinstance(raw, dict):
|
|
47
|
+
raise ConfigurationError("control payload must be an object")
|
|
48
|
+
command = parse_control(raw)
|
|
49
|
+
except (
|
|
50
|
+
OSError,
|
|
51
|
+
json.JSONDecodeError,
|
|
52
|
+
ConfigurationError,
|
|
53
|
+
TypeError,
|
|
54
|
+
ValueError,
|
|
55
|
+
) as exc:
|
|
56
|
+
self._quarantine_file(path, exc)
|
|
57
|
+
continue
|
|
58
|
+
dest = self._archive / path.name
|
|
59
|
+
path.replace(dest)
|
|
60
|
+
commands.append(command)
|
|
61
|
+
return commands
|
|
62
|
+
|
|
63
|
+
def _quarantine_file(self, path: Path, exc: BaseException) -> None:
|
|
64
|
+
dest = self._quarantine / path.name
|
|
65
|
+
try:
|
|
66
|
+
path.replace(dest)
|
|
67
|
+
except OSError: # pragma: no cover — leave in place if move fails
|
|
68
|
+
dest = path
|
|
69
|
+
self._logger.warning(
|
|
70
|
+
"control.quarantined",
|
|
71
|
+
path=str(dest),
|
|
72
|
+
error=str(exc),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CompositeRunControl:
|
|
77
|
+
"""Merge polls from several RunControl adapters (drain + inbox)."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *controls: RunControl) -> None:
|
|
80
|
+
self._controls = controls
|
|
81
|
+
|
|
82
|
+
def poll(self) -> Sequence[ControlCommand]:
|
|
83
|
+
commands: list[ControlCommand] = []
|
|
84
|
+
for control in self._controls:
|
|
85
|
+
commands.extend(control.poll())
|
|
86
|
+
return commands
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["CompositeRunControl", "FileRunControl"]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Doctor environment adapter — probes ``codex`` CLI, auth, and capacity sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess # nosec B404 — fixed argv to ``codex`` only, never shell=True
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from codexloop.application.usecases.doctor import DoctorCheck, DoctorReport
|
|
13
|
+
|
|
14
|
+
MINIMUM_CODEX_VERSION: tuple[int, int, int] = (0, 40, 0)
|
|
15
|
+
_REQUIRED_EXEC_FLAGS = ("--json", "--ephemeral", "-c")
|
|
16
|
+
_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodexDoctorEnvironment:
|
|
20
|
+
"""Injectable doctor probes over ``codex`` and the local filesystem."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
environ: Mapping[str, str] | None = None,
|
|
26
|
+
which: Callable[[str], str | None] | None = None,
|
|
27
|
+
run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
28
|
+
home: Path | None = None,
|
|
29
|
+
minimum_version: tuple[int, int, int] = MINIMUM_CODEX_VERSION,
|
|
30
|
+
app_server_live: Callable[[], bool] | None = None,
|
|
31
|
+
rollout_live: Callable[[], bool] | None = None,
|
|
32
|
+
mcp_servers: Callable[[], Sequence[str]] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._environ = dict(environ) if environ is not None else dict(os.environ)
|
|
35
|
+
self._which = which if which is not None else shutil.which
|
|
36
|
+
self._run = run if run is not None else _default_run
|
|
37
|
+
self._home = home if home is not None else Path.home()
|
|
38
|
+
self._minimum = minimum_version
|
|
39
|
+
self._app_server_live = app_server_live
|
|
40
|
+
self._rollout_live = rollout_live
|
|
41
|
+
self._mcp_servers = mcp_servers
|
|
42
|
+
|
|
43
|
+
def diagnose(self, *, cwd: Path) -> DoctorReport:
|
|
44
|
+
checks: list[DoctorCheck] = []
|
|
45
|
+
codex = self._which("codex")
|
|
46
|
+
if codex is None:
|
|
47
|
+
checks.append(
|
|
48
|
+
DoctorCheck(
|
|
49
|
+
name="codex-cli",
|
|
50
|
+
passed=False,
|
|
51
|
+
detail="`codex` not found on PATH",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
version_ok = False
|
|
55
|
+
version_text = None
|
|
56
|
+
else:
|
|
57
|
+
version_text = self._codex_version(codex)
|
|
58
|
+
version_ok = self._version_meets_floor(version_text)
|
|
59
|
+
checks.append(
|
|
60
|
+
DoctorCheck(
|
|
61
|
+
name="codex-cli",
|
|
62
|
+
passed=version_ok,
|
|
63
|
+
detail=(
|
|
64
|
+
f"found at {codex} ({version_text or 'version unknown'}); "
|
|
65
|
+
f"minimum {'.'.join(str(p) for p in self._minimum)}"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
login_ok = False
|
|
71
|
+
if codex is not None:
|
|
72
|
+
login_ok = self._login_status_ok(codex)
|
|
73
|
+
checks.append(
|
|
74
|
+
DoctorCheck(
|
|
75
|
+
name="login-status",
|
|
76
|
+
passed=login_ok,
|
|
77
|
+
detail="codex login status exit 0" if login_ok else "codex login status failed",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
flags_ok = False
|
|
82
|
+
if codex is not None:
|
|
83
|
+
flags_ok = self._exec_help_has_flags(codex)
|
|
84
|
+
checks.append(
|
|
85
|
+
DoctorCheck(
|
|
86
|
+
name="exec-flags",
|
|
87
|
+
passed=flags_ok,
|
|
88
|
+
detail=(
|
|
89
|
+
f"required flags present: {', '.join(_REQUIRED_EXEC_FLAGS)}"
|
|
90
|
+
if flags_ok
|
|
91
|
+
else f"missing one of {_REQUIRED_EXEC_FLAGS}"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
auth_mode = self._auth_mode()
|
|
97
|
+
checks.append(
|
|
98
|
+
DoctorCheck(
|
|
99
|
+
name="auth-mode",
|
|
100
|
+
passed=auth_mode != "none",
|
|
101
|
+
detail=f"active auth mode: {auth_mode}",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
strategies = {
|
|
106
|
+
"exec": True,
|
|
107
|
+
"app-server": (
|
|
108
|
+
self._app_server_live()
|
|
109
|
+
if self._app_server_live is not None
|
|
110
|
+
else self._probe_app_server_live()
|
|
111
|
+
),
|
|
112
|
+
"rollout": self._rollout_live() if self._rollout_live else False,
|
|
113
|
+
}
|
|
114
|
+
checks.append(
|
|
115
|
+
DoctorCheck(
|
|
116
|
+
name="probe-strategies",
|
|
117
|
+
passed=True,
|
|
118
|
+
detail=(
|
|
119
|
+
"live: "
|
|
120
|
+
+ ", ".join(name for name, live in strategies.items() if live)
|
|
121
|
+
+ (
|
|
122
|
+
"; unavailable: "
|
|
123
|
+
+ ", ".join(name for name, live in strategies.items() if not live)
|
|
124
|
+
if any(not live for live in strategies.values())
|
|
125
|
+
else ""
|
|
126
|
+
)
|
|
127
|
+
).rstrip("; "),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
mcp = list(self._mcp_servers()) if self._mcp_servers is not None else []
|
|
132
|
+
if mcp:
|
|
133
|
+
checks.append(
|
|
134
|
+
DoctorCheck(
|
|
135
|
+
name="mcp-oauth",
|
|
136
|
+
passed=False,
|
|
137
|
+
detail=(
|
|
138
|
+
f"MCP servers requiring OAuth: {', '.join(mcp)} — "
|
|
139
|
+
"authorize before an unattended run"
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
checks.append(
|
|
145
|
+
DoctorCheck(name="mcp-oauth", passed=True, detail="no MCP OAuth servers named")
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
is_git = (cwd / ".git").is_dir()
|
|
149
|
+
checks.append(
|
|
150
|
+
DoctorCheck(
|
|
151
|
+
name="working-directory",
|
|
152
|
+
passed=is_git,
|
|
153
|
+
detail=(
|
|
154
|
+
f"{cwd} is a git repository" if is_git else f"{cwd} is NOT a git repository"
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return DoctorReport(
|
|
160
|
+
checks=tuple(checks),
|
|
161
|
+
auth_mode=auth_mode,
|
|
162
|
+
probe_strategies=strategies,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _codex_version(self, path: str) -> str | None:
|
|
166
|
+
try:
|
|
167
|
+
result = self._run([path, "--version"], timeout=10)
|
|
168
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
169
|
+
return None
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
return None
|
|
172
|
+
text = (result.stdout or result.stderr or "").strip()
|
|
173
|
+
return text or None
|
|
174
|
+
|
|
175
|
+
def _version_meets_floor(self, version_text: str | None) -> bool:
|
|
176
|
+
if version_text is None:
|
|
177
|
+
return False
|
|
178
|
+
match = _VERSION_RE.search(version_text)
|
|
179
|
+
if match is None:
|
|
180
|
+
return False
|
|
181
|
+
parsed = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
182
|
+
return parsed >= self._minimum
|
|
183
|
+
|
|
184
|
+
def _login_status_ok(self, path: str) -> bool:
|
|
185
|
+
try:
|
|
186
|
+
result = self._run([path, "login", "status"], timeout=10)
|
|
187
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
188
|
+
return False
|
|
189
|
+
return result.returncode == 0
|
|
190
|
+
|
|
191
|
+
def _exec_help_has_flags(self, path: str) -> bool:
|
|
192
|
+
try:
|
|
193
|
+
result = self._run([path, "exec", "--help"], timeout=10)
|
|
194
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
195
|
+
return False
|
|
196
|
+
text = f"{result.stdout}\n{result.stderr}"
|
|
197
|
+
return all(flag in text for flag in _REQUIRED_EXEC_FLAGS)
|
|
198
|
+
|
|
199
|
+
def _auth_mode(self) -> str:
|
|
200
|
+
if self._environ.get("OPENAI_API_KEY") or self._environ.get("CODEX_API_KEY"):
|
|
201
|
+
return "api_key"
|
|
202
|
+
auth = self._home / ".codex" / "auth.json"
|
|
203
|
+
if auth.is_file():
|
|
204
|
+
return "chatgpt_plan"
|
|
205
|
+
return "none"
|
|
206
|
+
|
|
207
|
+
def _probe_app_server_live(self) -> bool:
|
|
208
|
+
"""Return True when ``codex app-server --help`` succeeds (cheap live probe)."""
|
|
209
|
+
codex = self._which("codex")
|
|
210
|
+
if codex is None:
|
|
211
|
+
return False
|
|
212
|
+
try:
|
|
213
|
+
result = self._run([codex, "app-server", "--help"], timeout=10)
|
|
214
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
215
|
+
return False
|
|
216
|
+
if result.returncode != 0:
|
|
217
|
+
return False
|
|
218
|
+
text = f"{result.stdout}\n{result.stderr}".lower()
|
|
219
|
+
return "stdio" in text or "app-server" in text
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _default_run(
|
|
223
|
+
argv: Sequence[str],
|
|
224
|
+
*,
|
|
225
|
+
timeout: float = 10,
|
|
226
|
+
) -> subprocess.CompletedProcess[str]:
|
|
227
|
+
return subprocess.run( # nosec B603
|
|
228
|
+
list(argv),
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
timeout=timeout,
|
|
232
|
+
check=False,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = [
|
|
237
|
+
"CodexDoctorEnvironment",
|
|
238
|
+
"MINIMUM_CODEX_VERSION",
|
|
239
|
+
]
|