continuous-refactoring 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.
@@ -0,0 +1,431 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from collections.abc import Mapping
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+
12
+ __all__ = [
13
+ "AttemptStats",
14
+ "CommandCapture",
15
+ "ContinuousRefactorError",
16
+ "RunArtifacts",
17
+ "create_run_artifacts",
18
+ "default_artifacts_root",
19
+ "iso_timestamp",
20
+ ]
21
+
22
+
23
+ class ContinuousRefactorError(RuntimeError):
24
+ pass
25
+
26
+
27
+ _UNSET = object()
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CommandCapture:
32
+ command: tuple[str, ...]
33
+ returncode: int
34
+ stdout: str
35
+ stderr: str
36
+ stdout_path: Path
37
+ stderr_path: Path
38
+
39
+
40
+ @dataclass
41
+ class AttemptStats:
42
+ attempt: int
43
+ target: str | None = None
44
+ retry: int | None = None
45
+ call_role: str | None = None
46
+ phase_reached: str | None = None
47
+ decision: str | None = None
48
+ retry_recommendation: str | None = None
49
+ failure_kind: str | None = None
50
+ failure_summary: str | None = None
51
+ reason_doc_path: str | None = None
52
+ commit_sha: str | None = None
53
+ commit_phase: str | None = None
54
+ requested_effort: str | None = None
55
+ effective_effort: str | None = None
56
+ max_allowed_effort: str | None = None
57
+ effort_source: str | None = None
58
+ effort_capped: bool | None = None
59
+ effort_reason: str | None = None
60
+
61
+
62
+ @dataclass
63
+ class RunArtifacts:
64
+ root: Path
65
+ run_id: str
66
+ repo_root: Path
67
+ agent: str
68
+ model: str
69
+ effort: str
70
+ test_command: str
71
+ events_path: Path
72
+ summary_path: Path
73
+ log_path: Path
74
+ started_at: str
75
+ default_effort: str | None = None
76
+ max_allowed_effort: str | None = None
77
+ finished_at: str | None = None
78
+ final_status: str = "running"
79
+ error_message: str | None = None
80
+ attempts: dict[int, AttemptStats] = field(default_factory=dict)
81
+ counts: dict[str, int] = field(
82
+ default_factory=lambda: {
83
+ "attempts_started": 0,
84
+ "commits_created": 0,
85
+ }
86
+ )
87
+
88
+ def attempt_dir(self, attempt: int, retry: int = 1) -> Path:
89
+ if attempt < 1:
90
+ raise ValueError(f"attempt must be >= 1, got {attempt}")
91
+ if retry < 1:
92
+ raise ValueError(f"retry must be >= 1, got {retry}")
93
+ base = self.root / f"attempt-{attempt:03d}"
94
+ path = base if retry == 1 else base / f"retry-{retry:02d}"
95
+ path.mkdir(parents=True, exist_ok=True)
96
+ return path
97
+
98
+ def baseline_dir(self, label: str) -> Path:
99
+ path = self.root / "baseline" / label
100
+ path.mkdir(parents=True, exist_ok=True)
101
+ return path
102
+
103
+ def ensure_attempt(self, attempt: int) -> AttemptStats:
104
+ if attempt not in self.attempts:
105
+ self.attempts[attempt] = AttemptStats(attempt=attempt)
106
+ return self.attempts[attempt]
107
+
108
+ def update_attempt(
109
+ self,
110
+ attempt: int,
111
+ *,
112
+ target: str | None | object = _UNSET,
113
+ retry: int | None | object = _UNSET,
114
+ call_role: str | None | object = _UNSET,
115
+ phase_reached: str | None | object = _UNSET,
116
+ decision: str | None | object = _UNSET,
117
+ retry_recommendation: str | None | object = _UNSET,
118
+ failure_kind: str | None | object = _UNSET,
119
+ failure_summary: str | None | object = _UNSET,
120
+ reason_doc_path: Path | None | object = _UNSET,
121
+ effort: Mapping[str, object] | None | object = _UNSET,
122
+ ) -> None:
123
+ stats = self.ensure_attempt(attempt)
124
+ if target is not _UNSET:
125
+ stats.target = target
126
+ if retry is not _UNSET:
127
+ stats.retry = retry
128
+ if call_role is not _UNSET:
129
+ stats.call_role = call_role
130
+ if phase_reached is not _UNSET:
131
+ stats.phase_reached = phase_reached
132
+ if decision is not _UNSET:
133
+ stats.decision = decision
134
+ if retry_recommendation is not _UNSET:
135
+ stats.retry_recommendation = retry_recommendation
136
+ if failure_kind is not _UNSET:
137
+ stats.failure_kind = failure_kind
138
+ if failure_summary is not _UNSET:
139
+ stats.failure_summary = failure_summary
140
+ if reason_doc_path is not _UNSET:
141
+ stats.reason_doc_path = (
142
+ str(reason_doc_path) if reason_doc_path is not None else None
143
+ )
144
+ if effort is not _UNSET:
145
+ _apply_effort_fields(stats, effort if effort is not None else None)
146
+ self.write_summary()
147
+
148
+ def log(self, level: str, message: str, **fields: object) -> None:
149
+ timestamp = iso_timestamp()
150
+ line = f"[{level}] {message}"
151
+ print(line)
152
+ with self.log_path.open("a", encoding="utf-8") as handle:
153
+ handle.write(f"[{timestamp}] {line}\n")
154
+ event = {"timestamp": timestamp, "level": level, "message": message, **fields}
155
+ _append_event(self.events_path, event)
156
+ self.write_summary()
157
+
158
+ def mark_attempt_started(self, attempt: int) -> None:
159
+ self.counts["attempts_started"] += 1
160
+ self.ensure_attempt(attempt)
161
+ self.write_summary()
162
+
163
+ def log_call_started(
164
+ self,
165
+ *,
166
+ attempt: int,
167
+ retry: int,
168
+ target: str,
169
+ display_target: str | None = None,
170
+ call_role: str,
171
+ phase_reached: str | None = None,
172
+ effort: Mapping[str, object] | None = None,
173
+ ) -> None:
174
+ self.update_attempt(
175
+ attempt,
176
+ target=target,
177
+ retry=retry,
178
+ call_role=call_role,
179
+ phase_reached=phase_reached or call_role,
180
+ effort=effort,
181
+ )
182
+ effort_fields = dict(effort or {})
183
+ human_target = display_target or target
184
+ self.log(
185
+ "INFO",
186
+ f"call start: {call_role} — {human_target}{_effort_log_suffix(effort)}",
187
+ event="call_started",
188
+ attempt=attempt,
189
+ retry=retry,
190
+ target=target,
191
+ call_role=call_role,
192
+ phase_reached=phase_reached or call_role,
193
+ **effort_fields,
194
+ )
195
+
196
+ def log_call_finished(
197
+ self,
198
+ *,
199
+ attempt: int,
200
+ retry: int,
201
+ target: str,
202
+ call_role: str,
203
+ display_target: str | None = None,
204
+ phase_reached: str | None = None,
205
+ status: str,
206
+ level: str = "INFO",
207
+ returncode: int | None = None,
208
+ summary: str | None = None,
209
+ effort: Mapping[str, object] | None = None,
210
+ ) -> None:
211
+ self.update_attempt(
212
+ attempt,
213
+ target=target,
214
+ retry=retry,
215
+ call_role=call_role,
216
+ phase_reached=phase_reached or call_role,
217
+ failure_summary=summary,
218
+ effort=effort,
219
+ )
220
+ effort_fields = dict(effort or {})
221
+ human_target = display_target or target
222
+ self.log(
223
+ level,
224
+ f"call {status}: {call_role} — {human_target}"
225
+ f"{_effort_log_suffix(effort)}",
226
+ event="call_finished",
227
+ attempt=attempt,
228
+ retry=retry,
229
+ target=target,
230
+ call_role=call_role,
231
+ phase_reached=phase_reached or call_role,
232
+ call_status=status,
233
+ returncode=returncode,
234
+ summary=summary,
235
+ **effort_fields,
236
+ )
237
+
238
+ def log_transition(
239
+ self,
240
+ *,
241
+ attempt: int,
242
+ retry: int,
243
+ target: str,
244
+ call_role: str,
245
+ phase_reached: str,
246
+ decision: str,
247
+ retry_recommendation: str,
248
+ failure_kind: str,
249
+ summary: str,
250
+ reason_doc_path: Path | None,
251
+ ) -> None:
252
+ self.update_attempt(
253
+ attempt,
254
+ target=target,
255
+ retry=retry,
256
+ call_role=call_role,
257
+ phase_reached=phase_reached,
258
+ decision=decision,
259
+ retry_recommendation=retry_recommendation,
260
+ failure_kind=failure_kind,
261
+ failure_summary=summary,
262
+ reason_doc_path=reason_doc_path,
263
+ )
264
+ self.log(
265
+ "WARN",
266
+ f"target transition: {decision}/{retry_recommendation} — {target}",
267
+ event="target_transition",
268
+ attempt=attempt,
269
+ retry=retry,
270
+ target=target,
271
+ call_role=call_role,
272
+ phase_reached=phase_reached,
273
+ decision=decision,
274
+ retry_recommendation=retry_recommendation,
275
+ failure_kind=failure_kind,
276
+ summary=summary,
277
+ reason_doc_path=str(reason_doc_path) if reason_doc_path else None,
278
+ )
279
+
280
+ def record_commit(self, attempt: int, phase: str, commit_sha: str) -> None:
281
+ stats = self.ensure_attempt(attempt)
282
+ stats.commit_sha = commit_sha
283
+ stats.commit_phase = phase
284
+ self.counts["commits_created"] += 1
285
+ self.write_summary()
286
+
287
+ def finish(self, status: str, error_message: str | None = None) -> None:
288
+ self.finished_at = iso_timestamp()
289
+ self.final_status = status
290
+ self.error_message = error_message
291
+ self.write_summary()
292
+
293
+ def write_summary(self) -> None:
294
+ summary = {
295
+ "run_id": self.run_id,
296
+ "artifact_root": str(self.root),
297
+ "repo_root": str(self.repo_root),
298
+ "agent": self.agent,
299
+ "model": self.model,
300
+ "effort": self.effort,
301
+ "default_effort": self.default_effort or self.effort,
302
+ "max_allowed_effort": self.max_allowed_effort or self.effort,
303
+ "test_command": self.test_command,
304
+ "started_at": self.started_at,
305
+ "finished_at": self.finished_at,
306
+ "final_status": self.final_status,
307
+ "error_message": self.error_message,
308
+ "counts": self.counts,
309
+ "attempts": [asdict(self.attempts[key]) for key in sorted(self.attempts)],
310
+ }
311
+ content = _serialize_summary(self.summary_path, summary)
312
+ _write_text_atomic(self.summary_path, content)
313
+
314
+
315
+ def iso_timestamp() -> str:
316
+ return _now().isoformat(timespec="milliseconds")
317
+
318
+
319
+ def _now() -> datetime:
320
+ return datetime.now().astimezone()
321
+
322
+
323
+ def default_artifacts_root() -> Path:
324
+ return Path(os.environ.get("TMPDIR") or tempfile.gettempdir())
325
+
326
+
327
+ def _append_event(path: Path, event: Mapping[str, object]) -> None:
328
+ try:
329
+ with path.open("a", encoding="utf-8") as handle:
330
+ handle.write(json.dumps(event, sort_keys=True) + "\n")
331
+ except OSError as exc:
332
+ raise ContinuousRefactorError(f"Could not append artifact event to {path}") from exc
333
+
334
+
335
+ def _serialize_summary(path: Path, summary: Mapping[str, object]) -> str:
336
+ try:
337
+ return json.dumps(summary, indent=2, sort_keys=True) + "\n"
338
+ except (TypeError, ValueError) as exc:
339
+ raise ContinuousRefactorError(f"Could not serialize run summary for {path}") from exc
340
+
341
+
342
+ def _write_text_atomic(path: Path, content: str) -> None:
343
+ tmp_path: Path | None = None
344
+ try:
345
+ path.parent.mkdir(parents=True, exist_ok=True)
346
+ with tempfile.NamedTemporaryFile(
347
+ mode="w", encoding="utf-8", dir=path.parent, suffix=".tmp", delete=False
348
+ ) as tmp:
349
+ tmp_path = Path(tmp.name)
350
+ tmp.write(content)
351
+ os.replace(tmp_path, path)
352
+ except OSError as exc:
353
+ if tmp_path is not None:
354
+ tmp_path.unlink(missing_ok=True)
355
+ raise ContinuousRefactorError(f"Could not write artifact file {path}") from exc
356
+
357
+
358
+ def create_run_artifacts(
359
+ repo_root: Path,
360
+ *,
361
+ agent: str,
362
+ model: str,
363
+ effort: str,
364
+ default_effort: str | None = None,
365
+ max_allowed_effort: str | None = None,
366
+ test_command: str,
367
+ ) -> RunArtifacts:
368
+ started_at_dt = _now()
369
+ started_at = started_at_dt.isoformat(timespec="milliseconds")
370
+ run_id = started_at_dt.strftime("%Y%m%dT%H%M%S-%f")
371
+ root = default_artifacts_root() / "continuous-refactoring" / run_id
372
+ root.mkdir(parents=True, exist_ok=False)
373
+ artifacts = RunArtifacts(
374
+ root=root,
375
+ run_id=run_id,
376
+ repo_root=repo_root,
377
+ agent=agent,
378
+ model=model,
379
+ effort=effort,
380
+ default_effort=default_effort or effort,
381
+ max_allowed_effort=max_allowed_effort or effort,
382
+ test_command=test_command,
383
+ events_path=root / "events.jsonl",
384
+ summary_path=root / "summary.json",
385
+ log_path=root / "run.log",
386
+ started_at=started_at,
387
+ )
388
+ artifacts.write_summary()
389
+ return artifacts
390
+
391
+
392
+ def _apply_effort_fields(
393
+ stats: AttemptStats,
394
+ effort: Mapping[str, object] | None,
395
+ ) -> None:
396
+ if effort is None:
397
+ return
398
+ fields = {
399
+ "requested_effort",
400
+ "effective_effort",
401
+ "max_allowed_effort",
402
+ "effort_source",
403
+ "effort_capped",
404
+ "effort_reason",
405
+ }
406
+ for field_name in fields:
407
+ if field_name not in effort:
408
+ continue
409
+ value = effort[field_name]
410
+ setattr(stats, field_name, value if value is not None else None)
411
+
412
+
413
+ def _effort_log_suffix(effort: Mapping[str, object] | None) -> str:
414
+ if not effort:
415
+ return ""
416
+ effective = _string_field(effort.get("effective_effort"))
417
+ requested = _string_field(effort.get("requested_effort"))
418
+ selected = effective or requested
419
+ if selected is None:
420
+ return ""
421
+ parts = [f"effort={selected}"]
422
+ if effective is not None and requested is not None and requested != effective:
423
+ parts.append(f"requested={requested}")
424
+ return f" ({' '.join(parts)})"
425
+
426
+
427
+ def _string_field(value: object) -> str | None:
428
+ if value is None:
429
+ return None
430
+ text = str(value).strip()
431
+ return text or None