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.
Files changed (104) hide show
  1. codexloop/__init__.py +3 -0
  2. codexloop/application/__init__.py +1 -0
  3. codexloop/application/dto.py +41 -0
  4. codexloop/application/ports.py +158 -0
  5. codexloop/application/runner.py +467 -0
  6. codexloop/application/usecases/__init__.py +1 -0
  7. codexloop/application/usecases/doctor.py +37 -0
  8. codexloop/application/usecases/list_threads.py +14 -0
  9. codexloop/application/usecases/preflight.py +10 -0
  10. codexloop/application/usecases/resume_thread.py +11 -0
  11. codexloop/application/usecases/run_control.py +20 -0
  12. codexloop/application/usecases/run_plan.py +11 -0
  13. codexloop/bootstrap.py +461 -0
  14. codexloop/cli/__init__.py +1 -0
  15. codexloop/cli/app.py +94 -0
  16. codexloop/cli/asyncio.py +80 -0
  17. codexloop/cli/commands/__init__.py +1 -0
  18. codexloop/cli/commands/approval_cmd.py +24 -0
  19. codexloop/cli/commands/capacity.py +33 -0
  20. codexloop/cli/commands/cwd_cmd.py +22 -0
  21. codexloop/cli/commands/doctor.py +27 -0
  22. codexloop/cli/commands/effort_cmd.py +24 -0
  23. codexloop/cli/commands/logs.py +15 -0
  24. codexloop/cli/commands/model_cmd.py +22 -0
  25. codexloop/cli/commands/prompt.py +32 -0
  26. codexloop/cli/commands/reset.py +25 -0
  27. codexloop/cli/commands/resume.py +38 -0
  28. codexloop/cli/commands/run.py +50 -0
  29. codexloop/cli/commands/runs.py +13 -0
  30. codexloop/cli/commands/sandbox_cmd.py +24 -0
  31. codexloop/cli/commands/savepoints.py +25 -0
  32. codexloop/cli/commands/snapshot.py +26 -0
  33. codexloop/cli/commands/status.py +15 -0
  34. codexloop/cli/commands/stop.py +21 -0
  35. codexloop/cli/commands/threads.py +15 -0
  36. codexloop/cli/commands/unwind.py +24 -0
  37. codexloop/cli/commands/watch.py +66 -0
  38. codexloop/cli/render.py +39 -0
  39. codexloop/domain/__init__.py +26 -0
  40. codexloop/domain/approval.py +38 -0
  41. codexloop/domain/backoff.py +34 -0
  42. codexloop/domain/budget.py +56 -0
  43. codexloop/domain/capacity.py +81 -0
  44. codexloop/domain/classify.py +98 -0
  45. codexloop/domain/completion.py +130 -0
  46. codexloop/domain/control.py +167 -0
  47. codexloop/domain/error_codes.py +75 -0
  48. codexloop/domain/errors.py +35 -0
  49. codexloop/domain/loop.py +190 -0
  50. codexloop/domain/model_profile.py +30 -0
  51. codexloop/domain/plan.py +38 -0
  52. codexloop/domain/savepoint.py +32 -0
  53. codexloop/domain/savepoint_message.py +56 -0
  54. codexloop/domain/session.py +32 -0
  55. codexloop/domain/signals.py +25 -0
  56. codexloop/domain/waiting.py +120 -0
  57. codexloop/infrastructure/__init__.py +0 -0
  58. codexloop/infrastructure/agent/__init__.py +0 -0
  59. codexloop/infrastructure/agent/argv.py +102 -0
  60. codexloop/infrastructure/agent/events.py +274 -0
  61. codexloop/infrastructure/agent/gateway.py +189 -0
  62. codexloop/infrastructure/agent/probe.py +74 -0
  63. codexloop/infrastructure/agent/process.py +208 -0
  64. codexloop/infrastructure/agent/schema.py +31 -0
  65. codexloop/infrastructure/agent/scripted.py +201 -0
  66. codexloop/infrastructure/agent/translate.py +120 -0
  67. codexloop/infrastructure/api/__init__.py +26 -0
  68. codexloop/infrastructure/api/api_baseline.json +340 -0
  69. codexloop/infrastructure/api/binder.py +170 -0
  70. codexloop/infrastructure/api/gateway.py +142 -0
  71. codexloop/infrastructure/api/introspect.py +248 -0
  72. codexloop/infrastructure/api/json_io.py +26 -0
  73. codexloop/infrastructure/api/params.py +162 -0
  74. codexloop/infrastructure/api/providers.py +70 -0
  75. codexloop/infrastructure/api/registry.py +13 -0
  76. codexloop/infrastructure/appserver/__init__.py +6 -0
  77. codexloop/infrastructure/appserver/client.py +245 -0
  78. codexloop/infrastructure/appserver/gateway.py +437 -0
  79. codexloop/infrastructure/appserver/ratelimits.py +100 -0
  80. codexloop/infrastructure/audit.py +26 -0
  81. codexloop/infrastructure/capacity_probe.py +57 -0
  82. codexloop/infrastructure/clock.py +26 -0
  83. codexloop/infrastructure/config.py +150 -0
  84. codexloop/infrastructure/control.py +89 -0
  85. codexloop/infrastructure/doctor_env.py +239 -0
  86. codexloop/infrastructure/events.py +23 -0
  87. codexloop/infrastructure/git_savepoints.py +176 -0
  88. codexloop/infrastructure/lock.py +88 -0
  89. codexloop/infrastructure/logging.py +124 -0
  90. codexloop/infrastructure/notify.py +27 -0
  91. codexloop/infrastructure/progress.py +14 -0
  92. codexloop/infrastructure/redact.py +52 -0
  93. codexloop/infrastructure/rollout.py +113 -0
  94. codexloop/infrastructure/rundir.py +57 -0
  95. codexloop/infrastructure/snapshot.py +39 -0
  96. codexloop/infrastructure/state.py +32 -0
  97. codexloop/infrastructure/state_bus.py +27 -0
  98. codexloop/infrastructure/stream_ui.py +44 -0
  99. codexloop/py.typed +0 -0
  100. codexloop-0.1.0.dist-info/METADATA +104 -0
  101. codexloop-0.1.0.dist-info/RECORD +104 -0
  102. codexloop-0.1.0.dist-info/WHEEL +4 -0
  103. codexloop-0.1.0.dist-info/entry_points.txt +2 -0
  104. 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"