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,23 @@
|
|
|
1
|
+
"""RunEventSink — append-only JSONL under a run directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from codexloop.infrastructure.redact import redact
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JsonlRunEventSink:
|
|
13
|
+
def __init__(self, path: Path) -> None:
|
|
14
|
+
self._path = path
|
|
15
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
if not self._path.exists():
|
|
17
|
+
self._path.touch()
|
|
18
|
+
|
|
19
|
+
def emit(self, event: Mapping[str, object]) -> None:
|
|
20
|
+
safe = redact(dict(event))
|
|
21
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
22
|
+
handle.write(json.dumps(safe, default=str) + "\n")
|
|
23
|
+
handle.flush()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Git-backed save points under ``refs/codexloop/<run_id>/<n>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess # nosec B404 — argv lists are fixed git subcommands, never shell=True
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codexloop.domain.errors import ConfigurationError
|
|
11
|
+
from codexloop.domain.savepoint import SavePointRef, UnwindResult
|
|
12
|
+
from codexloop.domain.savepoint_message import format_savepoint_commit_message
|
|
13
|
+
|
|
14
|
+
_CONTROL_PLANE_DIR = ".codexloop"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GitSavePointStore:
|
|
18
|
+
def __init__(self, *, cwd: Path, index_path: Path) -> None:
|
|
19
|
+
self._cwd = cwd
|
|
20
|
+
self._index_path = index_path
|
|
21
|
+
self._index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
if not self._index_path.exists():
|
|
23
|
+
self._index_path.touch()
|
|
24
|
+
|
|
25
|
+
def create(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
run_id: str,
|
|
29
|
+
label: str,
|
|
30
|
+
message: str | None = None,
|
|
31
|
+
attempt: int | None = None,
|
|
32
|
+
verdict_name: str = "Continue",
|
|
33
|
+
summary: str = "",
|
|
34
|
+
remaining_work: tuple[str, ...] = (),
|
|
35
|
+
) -> SavePointRef | None:
|
|
36
|
+
if not self._is_git_repo():
|
|
37
|
+
return None
|
|
38
|
+
self._run(["git", "add", "-A"])
|
|
39
|
+
self._run(["git", "reset", "-q", "--", _CONTROL_PLANE_DIR], check=False)
|
|
40
|
+
has_staged = self._run(["git", "diff", "--cached", "--quiet"], check=False).returncode != 0
|
|
41
|
+
changed_paths = self._staged_paths() if has_staged else ()
|
|
42
|
+
turn_n = attempt if attempt is not None else self._next_n(run_id)
|
|
43
|
+
subject: str | None = None
|
|
44
|
+
if has_staged:
|
|
45
|
+
subject, body = format_savepoint_commit_message(
|
|
46
|
+
run_id=run_id,
|
|
47
|
+
attempt=turn_n,
|
|
48
|
+
verdict_name=verdict_name,
|
|
49
|
+
summary=summary or message or "",
|
|
50
|
+
remaining_work=remaining_work,
|
|
51
|
+
changed_paths=changed_paths,
|
|
52
|
+
label=label,
|
|
53
|
+
)
|
|
54
|
+
self._run(
|
|
55
|
+
["git", "commit", "--no-verify", "-m", subject, "-m", body],
|
|
56
|
+
)
|
|
57
|
+
sha = self._run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
|
58
|
+
n = self._next_n(run_id)
|
|
59
|
+
ref = f"refs/codexloop/{run_id}/{n}"
|
|
60
|
+
self._run(["git", "update-ref", ref, sha])
|
|
61
|
+
point = SavePointRef(
|
|
62
|
+
n=n,
|
|
63
|
+
ref=ref,
|
|
64
|
+
sha=sha,
|
|
65
|
+
label=label,
|
|
66
|
+
at=datetime.now(UTC),
|
|
67
|
+
plan_item=None,
|
|
68
|
+
committed=has_staged,
|
|
69
|
+
)
|
|
70
|
+
self._append_index(
|
|
71
|
+
point,
|
|
72
|
+
committed=has_staged,
|
|
73
|
+
subject=subject,
|
|
74
|
+
path_count=len(changed_paths),
|
|
75
|
+
)
|
|
76
|
+
return point
|
|
77
|
+
|
|
78
|
+
def list_points(self, run_id: str) -> list[SavePointRef]:
|
|
79
|
+
if not self._index_path.is_file():
|
|
80
|
+
return []
|
|
81
|
+
points: list[SavePointRef] = []
|
|
82
|
+
for line in self._index_path.read_text(encoding="utf-8").splitlines():
|
|
83
|
+
if not line.strip():
|
|
84
|
+
continue
|
|
85
|
+
data = json.loads(line)
|
|
86
|
+
if data.get("run_id") and data["run_id"] != run_id:
|
|
87
|
+
continue
|
|
88
|
+
points.append(
|
|
89
|
+
SavePointRef(
|
|
90
|
+
n=int(data["n"]),
|
|
91
|
+
ref=str(data["ref"]),
|
|
92
|
+
sha=str(data["sha"]),
|
|
93
|
+
label=str(data["label"]),
|
|
94
|
+
at=datetime.fromisoformat(data["at"]),
|
|
95
|
+
plan_item=data.get("plan_item"),
|
|
96
|
+
committed=bool(data.get("committed", False)),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return points
|
|
100
|
+
|
|
101
|
+
def unwind(self, *, run_id: str, to: str, backup: bool, live: bool = False) -> UnwindResult:
|
|
102
|
+
if live:
|
|
103
|
+
raise ConfigurationError("unwind refuses while a run is live")
|
|
104
|
+
points = self.list_points(run_id)
|
|
105
|
+
target = self._resolve_target(points, to)
|
|
106
|
+
backup_ref: str | None = None
|
|
107
|
+
if backup:
|
|
108
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
109
|
+
backup_ref = f"refs/codexloop/backup/{run_id}/{stamp}"
|
|
110
|
+
head = self._run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
|
111
|
+
self._run(["git", "update-ref", backup_ref, head])
|
|
112
|
+
self._run(["git", "reset", "--hard", target.sha])
|
|
113
|
+
return UnwindResult(to=target, backup_ref=backup_ref, restored_sha=target.sha)
|
|
114
|
+
|
|
115
|
+
def _next_n(self, run_id: str) -> int:
|
|
116
|
+
existing = self.list_points(run_id)
|
|
117
|
+
return (existing[-1].n + 1) if existing else 1
|
|
118
|
+
|
|
119
|
+
def _staged_paths(self) -> tuple[str, ...]:
|
|
120
|
+
result = self._run(["git", "diff", "--cached", "--name-only", "-z"], check=False)
|
|
121
|
+
if result.returncode != 0 or not result.stdout:
|
|
122
|
+
return ()
|
|
123
|
+
return tuple(p for p in result.stdout.split("\0") if p)
|
|
124
|
+
|
|
125
|
+
def _append_index(
|
|
126
|
+
self,
|
|
127
|
+
point: SavePointRef,
|
|
128
|
+
*,
|
|
129
|
+
committed: bool = False,
|
|
130
|
+
subject: str | None = None,
|
|
131
|
+
path_count: int = 0,
|
|
132
|
+
) -> None:
|
|
133
|
+
parts = point.ref.split("/")
|
|
134
|
+
run_id = parts[2] if len(parts) >= 4 else ""
|
|
135
|
+
entry = {
|
|
136
|
+
"run_id": run_id,
|
|
137
|
+
"n": point.n,
|
|
138
|
+
"ref": point.ref,
|
|
139
|
+
"sha": point.sha,
|
|
140
|
+
"label": point.label,
|
|
141
|
+
"at": point.at.isoformat(),
|
|
142
|
+
"plan_item": point.plan_item,
|
|
143
|
+
"committed": committed,
|
|
144
|
+
"subject": subject,
|
|
145
|
+
"path_count": path_count,
|
|
146
|
+
}
|
|
147
|
+
with self._index_path.open("a", encoding="utf-8") as handle:
|
|
148
|
+
handle.write(json.dumps(entry) + "\n")
|
|
149
|
+
|
|
150
|
+
def _resolve_target(self, points: list[SavePointRef], to: str) -> SavePointRef:
|
|
151
|
+
if to.isdigit():
|
|
152
|
+
n = int(to)
|
|
153
|
+
for point in points:
|
|
154
|
+
if point.n == n:
|
|
155
|
+
return point
|
|
156
|
+
raise ValueError(f"no save point numbered {n}")
|
|
157
|
+
for point in points:
|
|
158
|
+
if point.ref == to or point.sha.startswith(to) or point.label == to:
|
|
159
|
+
return point
|
|
160
|
+
raise ValueError(f"no save point matching {to!r}")
|
|
161
|
+
|
|
162
|
+
def _is_git_repo(self) -> bool:
|
|
163
|
+
result = self._run(["git", "rev-parse", "--is-inside-work-tree"], check=False)
|
|
164
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
165
|
+
|
|
166
|
+
def _run(self, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
167
|
+
return subprocess.run( # nosec B603
|
|
168
|
+
args,
|
|
169
|
+
cwd=self._cwd,
|
|
170
|
+
check=check,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["GitSavePointStore"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""SessionLock — advisory file lock keyed by thread id, with stale-pid break."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from codexloop.application.ports import Logger
|
|
10
|
+
|
|
11
|
+
_LOG = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AdvisoryFileLock:
|
|
15
|
+
def __init__(self, directory: Path, *, logger: Logger | None = None) -> None:
|
|
16
|
+
self._directory = directory
|
|
17
|
+
self._directory.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
self._logger = logger
|
|
19
|
+
|
|
20
|
+
def _path(self, thread_id: str) -> Path:
|
|
21
|
+
return self._directory / f"{thread_id}.lock"
|
|
22
|
+
|
|
23
|
+
def acquire(self, thread_id: str) -> bool:
|
|
24
|
+
path = self._path(thread_id)
|
|
25
|
+
if path.is_file():
|
|
26
|
+
pid = _read_pid(path)
|
|
27
|
+
if pid is None or _pid_alive(pid):
|
|
28
|
+
# Empty/unreadable/unparseable lockfile, or live/unknown pid: held.
|
|
29
|
+
return False
|
|
30
|
+
self._log_stale(thread_id, pid, "process is dead")
|
|
31
|
+
path.unlink(missing_ok=True)
|
|
32
|
+
return _publish_lock(path)
|
|
33
|
+
|
|
34
|
+
def release(self, thread_id: str) -> None:
|
|
35
|
+
self._path(thread_id).unlink(missing_ok=True)
|
|
36
|
+
|
|
37
|
+
def _log_stale(self, thread_id: str, pid: int | None, reason: str) -> None:
|
|
38
|
+
if self._logger is not None:
|
|
39
|
+
self._logger.warning(
|
|
40
|
+
"stale_lock_broken",
|
|
41
|
+
thread_id=thread_id,
|
|
42
|
+
pid=pid,
|
|
43
|
+
reason=reason,
|
|
44
|
+
)
|
|
45
|
+
return
|
|
46
|
+
_LOG.warning(
|
|
47
|
+
"stale lock broken for thread %s (pid=%s): %s",
|
|
48
|
+
thread_id,
|
|
49
|
+
pid,
|
|
50
|
+
reason,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _publish_lock(path: Path) -> bool:
|
|
55
|
+
"""Atomically publish a non-empty lockfile (write pid, then link into place)."""
|
|
56
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
57
|
+
try:
|
|
58
|
+
tmp.write_text(f"{os.getpid()}\n", encoding="utf-8")
|
|
59
|
+
os.link(str(tmp), str(path))
|
|
60
|
+
except FileExistsError:
|
|
61
|
+
return False
|
|
62
|
+
except OSError:
|
|
63
|
+
return False
|
|
64
|
+
finally:
|
|
65
|
+
tmp.unlink(missing_ok=True)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_pid(path: Path) -> int | None:
|
|
70
|
+
try:
|
|
71
|
+
return int(path.read_text(encoding="utf-8").strip())
|
|
72
|
+
except (OSError, ValueError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pid_alive(pid: int) -> bool:
|
|
77
|
+
if pid <= 0:
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
os.kill(pid, 0)
|
|
81
|
+
except ProcessLookupError:
|
|
82
|
+
return False
|
|
83
|
+
except PermissionError:
|
|
84
|
+
return True
|
|
85
|
+
except OSError:
|
|
86
|
+
# Unknown errno: treat as alive so we never break a maybe-held lock.
|
|
87
|
+
return True
|
|
88
|
+
return True
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""structlog configuration: human console, optional JSON console, optional file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import MutableMapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
from structlog.stdlib import BoundLogger, LoggerFactory, ProcessorFormatter
|
|
13
|
+
|
|
14
|
+
from codexloop.application.ports import Logger
|
|
15
|
+
from codexloop.infrastructure.redact import redact
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RedactionProcessor:
|
|
19
|
+
"""structlog processor that recursively redacts secrets in the event dict."""
|
|
20
|
+
|
|
21
|
+
def __call__(
|
|
22
|
+
self,
|
|
23
|
+
_logger: object,
|
|
24
|
+
_method_name: str,
|
|
25
|
+
event_dict: MutableMapping[str, Any],
|
|
26
|
+
) -> MutableMapping[str, Any]:
|
|
27
|
+
redacted = redact(dict(event_dict))
|
|
28
|
+
if not isinstance(redacted, dict):
|
|
29
|
+
return event_dict
|
|
30
|
+
return redacted
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _json_renderer(logger: object, method_name: str, event_dict: MutableMapping[str, Any]) -> str:
|
|
34
|
+
rendered = structlog.processors.JSONRenderer()(logger, method_name, event_dict)
|
|
35
|
+
return str(rendered)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def configure_logging(
|
|
39
|
+
*,
|
|
40
|
+
level: str = "INFO",
|
|
41
|
+
json_logs: bool = False,
|
|
42
|
+
log_file: Path | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Install a human console handler, optional JSON console, and optional file."""
|
|
45
|
+
level_value = getattr(logging, level.upper(), logging.INFO)
|
|
46
|
+
shared: list[Any] = [
|
|
47
|
+
structlog.contextvars.merge_contextvars,
|
|
48
|
+
structlog.stdlib.add_logger_name,
|
|
49
|
+
structlog.stdlib.add_log_level,
|
|
50
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
51
|
+
RedactionProcessor(),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
structlog.configure(
|
|
55
|
+
processors=[
|
|
56
|
+
structlog.stdlib.filter_by_level,
|
|
57
|
+
*shared,
|
|
58
|
+
ProcessorFormatter.wrap_for_formatter,
|
|
59
|
+
],
|
|
60
|
+
logger_factory=LoggerFactory(),
|
|
61
|
+
wrapper_class=structlog.make_filtering_bound_logger(level_value),
|
|
62
|
+
cache_logger_on_first_use=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
root = logging.getLogger()
|
|
66
|
+
root.handlers.clear()
|
|
67
|
+
root.setLevel(level_value)
|
|
68
|
+
|
|
69
|
+
human = logging.StreamHandler(sys.stderr)
|
|
70
|
+
human.setLevel(level_value)
|
|
71
|
+
human.setFormatter(
|
|
72
|
+
ProcessorFormatter(
|
|
73
|
+
processor=structlog.dev.ConsoleRenderer(),
|
|
74
|
+
foreign_pre_chain=shared,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
root.addHandler(human)
|
|
78
|
+
|
|
79
|
+
if json_logs:
|
|
80
|
+
json_console = logging.StreamHandler(sys.stderr)
|
|
81
|
+
json_console.setLevel(level_value)
|
|
82
|
+
json_console.setFormatter(
|
|
83
|
+
ProcessorFormatter(
|
|
84
|
+
processor=_json_renderer,
|
|
85
|
+
foreign_pre_chain=shared,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
root.addHandler(json_console)
|
|
89
|
+
|
|
90
|
+
if log_file is not None:
|
|
91
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
93
|
+
file_handler.setLevel(level_value)
|
|
94
|
+
file_handler.setFormatter(
|
|
95
|
+
ProcessorFormatter(
|
|
96
|
+
processor=_json_renderer,
|
|
97
|
+
foreign_pre_chain=shared,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
root.addHandler(file_handler)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_logger(**initial_context: Any) -> BoundLogger:
|
|
104
|
+
logger: BoundLogger = structlog.get_logger(**initial_context)
|
|
105
|
+
return logger
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class StructlogAppLogger:
|
|
109
|
+
"""Adapter satisfying :class:`codexloop.application.ports.Logger`."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, bound: BoundLogger | None = None, **context: Any) -> None:
|
|
112
|
+
self._log: BoundLogger = bound if bound is not None else get_logger(**context)
|
|
113
|
+
|
|
114
|
+
def bind(self, **kwargs: object) -> Logger:
|
|
115
|
+
return StructlogAppLogger(self._log.bind(**kwargs))
|
|
116
|
+
|
|
117
|
+
def info(self, event: str, **kwargs: object) -> None:
|
|
118
|
+
self._log.info(event, **kwargs)
|
|
119
|
+
|
|
120
|
+
def warning(self, event: str, **kwargs: object) -> None:
|
|
121
|
+
self._log.warning(event, **kwargs)
|
|
122
|
+
|
|
123
|
+
def error(self, event: str, **kwargs: object) -> None:
|
|
124
|
+
self._log.error(event, **kwargs)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Notifier — run a configured command, or record a no-op when unset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess # nosec B404 — argv list from operator config, never shell=True
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from codexloop.infrastructure.redact import redact_string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommandNotifier:
|
|
13
|
+
def __init__(self, command: str | Sequence[str] | None = None) -> None:
|
|
14
|
+
self._command = command
|
|
15
|
+
self.noop_notifications: list[tuple[str, str]] = []
|
|
16
|
+
|
|
17
|
+
def notify(self, title: str, body: str) -> None:
|
|
18
|
+
safe_title = redact_string(title)
|
|
19
|
+
safe_body = redact_string(body)
|
|
20
|
+
if not self._command:
|
|
21
|
+
self.noop_notifications.append((safe_title, safe_body))
|
|
22
|
+
return
|
|
23
|
+
argv = shlex.split(self._command) if isinstance(self._command, str) else list(self._command)
|
|
24
|
+
subprocess.run( # nosec B603 — no shell; operator-configured argv
|
|
25
|
+
[*argv, safe_title, safe_body],
|
|
26
|
+
check=False,
|
|
27
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ProgressReporter — log-based adapter for operator-visible run events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codexloop.application.ports import Logger
|
|
6
|
+
from codexloop.infrastructure.logging import StructlogAppLogger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LoggingProgressReporter:
|
|
10
|
+
def __init__(self, logger: Logger | None = None) -> None:
|
|
11
|
+
self._logger: Logger = logger if logger is not None else StructlogAppLogger()
|
|
12
|
+
|
|
13
|
+
def report(self, event: str, **detail: object) -> None:
|
|
14
|
+
self._logger.info(event, **detail)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Recursive redaction of secret-shaped keys and credential-looking strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
REDACTED_VALUE = "***REDACTED***"
|
|
8
|
+
|
|
9
|
+
_REDACTED_KEYS = frozenset(
|
|
10
|
+
{
|
|
11
|
+
"openai_api_key",
|
|
12
|
+
"codex_api_key",
|
|
13
|
+
"authorization",
|
|
14
|
+
"access_token",
|
|
15
|
+
"refresh_token",
|
|
16
|
+
"client_secret",
|
|
17
|
+
"api_key",
|
|
18
|
+
"secret_value",
|
|
19
|
+
"secret",
|
|
20
|
+
"password",
|
|
21
|
+
"token",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_SK_PATTERN = re.compile(r"sk-[A-Za-z0-9_-]{16,}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize_key(key: object) -> str:
|
|
29
|
+
return str(key).lower().replace("-", "_")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def redact_string(value: str) -> str:
|
|
33
|
+
return _SK_PATTERN.sub(REDACTED_VALUE, value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def redact[T](value: T) -> T:
|
|
37
|
+
"""Recursively scrub secret keys and ``sk-`` credential substrings."""
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
out: dict[object, object] = {}
|
|
40
|
+
for key, item in value.items():
|
|
41
|
+
if _normalize_key(key) in _REDACTED_KEYS:
|
|
42
|
+
out[key] = REDACTED_VALUE
|
|
43
|
+
else:
|
|
44
|
+
out[key] = redact(item)
|
|
45
|
+
return out # type: ignore[return-value]
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [redact(item) for item in value] # type: ignore[return-value]
|
|
48
|
+
if isinstance(value, tuple):
|
|
49
|
+
return tuple(redact(item) for item in value) # type: ignore[return-value]
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
return redact_string(value) # type: ignore[return-value]
|
|
52
|
+
return value
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Best-effort rollout-tail telemetry (confidence C, R5). Strictly read-only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from codexloop.domain.capacity import PlanWindows
|
|
10
|
+
from codexloop.infrastructure.agent.events import JsonlParser, RateLimitsUpdated
|
|
11
|
+
|
|
12
|
+
_DEFAULT_MAX_AGE = timedelta(minutes=10)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_rollout_rate_limits(
|
|
16
|
+
*,
|
|
17
|
+
codex_home: Path | None = None,
|
|
18
|
+
max_age: timedelta = _DEFAULT_MAX_AGE,
|
|
19
|
+
now: datetime | None = None,
|
|
20
|
+
) -> PlanWindows | None:
|
|
21
|
+
"""Return the newest contained rollout snapshot, or ``None``. Never raises."""
|
|
22
|
+
try:
|
|
23
|
+
return _read(codex_home=codex_home, max_age=max_age, now=now)
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read(
|
|
29
|
+
*,
|
|
30
|
+
codex_home: Path | None,
|
|
31
|
+
max_age: timedelta,
|
|
32
|
+
now: datetime | None,
|
|
33
|
+
) -> PlanWindows | None:
|
|
34
|
+
home = Path.home() / ".codex" if codex_home is None else Path(codex_home)
|
|
35
|
+
try:
|
|
36
|
+
root = home.resolve()
|
|
37
|
+
except OSError:
|
|
38
|
+
return None
|
|
39
|
+
if not root.is_dir():
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
clock = now if now is not None else datetime.now(UTC)
|
|
43
|
+
newest = _newest_contained_jsonl(root)
|
|
44
|
+
if newest is None:
|
|
45
|
+
return None
|
|
46
|
+
if _is_stale(newest, now=clock, max_age=max_age):
|
|
47
|
+
return None
|
|
48
|
+
return _parse_file(newest, now=clock)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _newest_contained_jsonl(root: Path) -> Path | None:
|
|
52
|
+
"""Pick the newest ``*.jsonl`` under ``root`` without following dir symlinks."""
|
|
53
|
+
newest: Path | None = None
|
|
54
|
+
newest_mtime = float("-inf")
|
|
55
|
+
try:
|
|
56
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
57
|
+
# Do not descend into symlinked directories (os.walk still lists them).
|
|
58
|
+
dirnames[:] = [name for name in dirnames if not Path(dirpath, name).is_symlink()]
|
|
59
|
+
for name in filenames:
|
|
60
|
+
if not name.endswith(".jsonl"):
|
|
61
|
+
continue
|
|
62
|
+
path = Path(dirpath) / name
|
|
63
|
+
# File symlinks are allowed only when the target stays under root.
|
|
64
|
+
if not _contained(path, root):
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
target = path.resolve() if path.is_symlink() else path
|
|
68
|
+
if not target.is_file():
|
|
69
|
+
continue
|
|
70
|
+
mtime = target.stat().st_mtime
|
|
71
|
+
except OSError: # pragma: no cover
|
|
72
|
+
continue
|
|
73
|
+
if mtime >= newest_mtime:
|
|
74
|
+
newest = target
|
|
75
|
+
newest_mtime = mtime
|
|
76
|
+
except OSError:
|
|
77
|
+
return None
|
|
78
|
+
return newest
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _contained(path: Path, root: Path) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
resolved = path.resolve()
|
|
84
|
+
except OSError:
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
return resolved.is_relative_to(root)
|
|
88
|
+
except ValueError: # pragma: no cover
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_stale(path: Path, *, now: datetime, max_age: timedelta) -> bool:
|
|
93
|
+
try:
|
|
94
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
|
|
95
|
+
except (OSError, OverflowError, ValueError):
|
|
96
|
+
return True
|
|
97
|
+
return now - mtime > max_age
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_file(path: Path, *, now: datetime) -> PlanWindows | None:
|
|
101
|
+
parser = JsonlParser(now=now)
|
|
102
|
+
last: PlanWindows | None = None
|
|
103
|
+
found = False
|
|
104
|
+
try:
|
|
105
|
+
with path.open(encoding="utf-8") as handle:
|
|
106
|
+
for line in handle:
|
|
107
|
+
event = parser.parse_line(line)
|
|
108
|
+
if isinstance(event, RateLimitsUpdated):
|
|
109
|
+
last = event.plan_windows
|
|
110
|
+
found = True
|
|
111
|
+
except (OSError, UnicodeDecodeError):
|
|
112
|
+
return None
|
|
113
|
+
return last if found else None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Per-run control directory layout under ``.codexloop/runs/<run_id>/``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunDirectory:
|
|
13
|
+
"""Filesystem layout for one autonomous run's control plane."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, root: Path) -> None:
|
|
16
|
+
self.root = root
|
|
17
|
+
self.run_id = root.name
|
|
18
|
+
self.meta_path = root / "meta.json"
|
|
19
|
+
self.state_path = root / "state.json"
|
|
20
|
+
self.events_path = root / "events.jsonl"
|
|
21
|
+
self.inbox = root / "inbox"
|
|
22
|
+
self.archive = root / "archive"
|
|
23
|
+
self.savepoints_path = root / "savepoints.jsonl"
|
|
24
|
+
self.snapshots = root / "snapshots"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def create(cls, runs_root: Path, *, run_id: str | None = None) -> RunDirectory:
|
|
28
|
+
if run_id is None:
|
|
29
|
+
run_id = str(uuid.uuid4())
|
|
30
|
+
directory = cls(runs_root / run_id)
|
|
31
|
+
directory.ensure_layout()
|
|
32
|
+
return directory
|
|
33
|
+
|
|
34
|
+
def ensure_layout(self) -> None:
|
|
35
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self.inbox.mkdir(exist_ok=True)
|
|
37
|
+
self.archive.mkdir(exist_ok=True)
|
|
38
|
+
(self.inbox / "archive").mkdir(exist_ok=True)
|
|
39
|
+
(self.inbox / "quarantine").mkdir(exist_ok=True)
|
|
40
|
+
self.snapshots.mkdir(exist_ok=True)
|
|
41
|
+
if not self.savepoints_path.is_file():
|
|
42
|
+
self.savepoints_path.touch()
|
|
43
|
+
if not self.meta_path.is_file():
|
|
44
|
+
meta = {
|
|
45
|
+
"run_id": self.run_id,
|
|
46
|
+
"pid": os.getpid(),
|
|
47
|
+
"started_at": datetime.now(UTC).isoformat(),
|
|
48
|
+
}
|
|
49
|
+
self.meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
50
|
+
if not self.state_path.is_file():
|
|
51
|
+
self.state_path.write_text("{}\n", encoding="utf-8")
|
|
52
|
+
if not self.events_path.is_file():
|
|
53
|
+
self.events_path.touch()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def runs_root_for(cwd: Path) -> Path:
|
|
57
|
+
return cwd / ".codexloop" / "runs"
|