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,190 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from typing import cast
6
+
7
+ from continuous_refactoring.artifacts import ContinuousRefactorError
8
+ from continuous_refactoring.effort import EffortTier, require_effort_tier
9
+ from continuous_refactoring.migrations import (
10
+ MIGRATION_STATUSES,
11
+ MigrationManifest,
12
+ MigrationStatus,
13
+ PhaseSpec,
14
+ )
15
+
16
+ __all__ = ("decode_manifest_payload", "encode_manifest_payload")
17
+
18
+ _VALID_STATUSES: frozenset[str] = frozenset(
19
+ cast(tuple[str, ...], MIGRATION_STATUSES)
20
+ )
21
+
22
+
23
+ def _require_status(raw_status: object) -> MigrationStatus:
24
+ if not isinstance(raw_status, str):
25
+ raise ContinuousRefactorError(
26
+ f"Migration status must be a string: {raw_status!r}"
27
+ )
28
+ if raw_status not in _VALID_STATUSES:
29
+ raise ContinuousRefactorError(f"Unknown migration status: {raw_status!r}")
30
+ return cast(MigrationStatus, raw_status)
31
+
32
+
33
+ def _require_mapping(value: object, *, field: str) -> dict[str, object]:
34
+ if not isinstance(value, dict):
35
+ raise ContinuousRefactorError(
36
+ f"Migration field {field!r} must be an object: {value!r}"
37
+ )
38
+ return value
39
+
40
+
41
+ def _require_str(value: object, *, field: str) -> str:
42
+ if not isinstance(value, str):
43
+ raise ContinuousRefactorError(
44
+ f"Migration field {field!r} must be a string: {value!r}"
45
+ )
46
+ return value
47
+
48
+
49
+ def _optional_str(value: object, *, field: str) -> str | None:
50
+ if value is None:
51
+ return None
52
+ return _require_str(value, field=field)
53
+
54
+
55
+ def _require_bool(value: object, *, field: str) -> bool:
56
+ if not isinstance(value, bool):
57
+ raise ContinuousRefactorError(
58
+ f"Migration field {field!r} must be a boolean: {value!r}"
59
+ )
60
+ return value
61
+
62
+
63
+ def _require_unique_phase_names(phases: tuple[PhaseSpec, ...]) -> None:
64
+ seen: set[str] = set()
65
+ duplicates: list[str] = []
66
+ for phase in phases:
67
+ if phase.name in seen:
68
+ duplicates.append(phase.name)
69
+ continue
70
+ seen.add(phase.name)
71
+ if duplicates:
72
+ repeated = ", ".join(sorted(set(duplicates)))
73
+ raise ContinuousRefactorError(
74
+ f"Duplicate phase names are not allowed: {repeated}"
75
+ )
76
+
77
+
78
+ def _require_phase_precondition(phase: dict[str, object], *, prefix: str) -> str:
79
+ if "precondition" in phase:
80
+ return _require_str(phase.get("precondition"), field=f"{prefix}.precondition")
81
+ if "ready_when" in phase:
82
+ return _require_str(phase.get("ready_when"), field=f"{prefix}.ready_when")
83
+ raise ContinuousRefactorError(
84
+ f"Migration field {prefix!r} must include 'precondition' "
85
+ "(or legacy 'ready_when')"
86
+ )
87
+
88
+
89
+ def _require_phase(raw_phase: object, *, index: int) -> PhaseSpec:
90
+ phase = _require_mapping(raw_phase, field=f"phases[{index}]")
91
+ prefix = f"phases[{index}]"
92
+ required_effort_raw = phase.get("required_effort")
93
+ required_effort: EffortTier | None = None
94
+ if required_effort_raw is not None:
95
+ required_effort = require_effort_tier(
96
+ required_effort_raw,
97
+ field=f"{prefix}.required_effort",
98
+ )
99
+ return PhaseSpec(
100
+ name=_require_str(phase.get("name"), field=f"{prefix}.name"),
101
+ file=_require_str(phase.get("file"), field=f"{prefix}.file"),
102
+ done=_require_bool(phase.get("done"), field=f"{prefix}.done"),
103
+ precondition=_require_phase_precondition(phase, prefix=prefix),
104
+ required_effort=required_effort,
105
+ effort_reason=_optional_str(
106
+ phase.get("effort_reason"), field=f"{prefix}.effort_reason"
107
+ ),
108
+ )
109
+
110
+
111
+ def _require_phases(raw_phases: object) -> tuple[PhaseSpec, ...]:
112
+ if raw_phases is None:
113
+ return ()
114
+ if not isinstance(raw_phases, list):
115
+ raise ContinuousRefactorError(
116
+ f"Migration field 'phases' must be a list: {raw_phases!r}"
117
+ )
118
+ phases = tuple(
119
+ _require_phase(raw_phase, index=index)
120
+ for index, raw_phase in enumerate(raw_phases)
121
+ )
122
+ _require_unique_phase_names(phases)
123
+ return phases
124
+
125
+
126
+ def _legacy_current_phase_name(
127
+ legacy_cursor: int, phases: tuple[PhaseSpec, ...],
128
+ ) -> str:
129
+ if not phases:
130
+ return ""
131
+ if 0 <= legacy_cursor < len(phases):
132
+ return phases[legacy_cursor].name
133
+ return ""
134
+
135
+
136
+ def _require_current_phase(value: object, *, phases: tuple[PhaseSpec, ...]) -> str:
137
+ if isinstance(value, bool):
138
+ raise ContinuousRefactorError(
139
+ f"Migration field 'current_phase' must be a string or legacy int: {value!r}"
140
+ )
141
+ if isinstance(value, str):
142
+ if value == "":
143
+ return ""
144
+ if not any(phase.name == value for phase in phases):
145
+ raise ContinuousRefactorError(
146
+ f"Migration field 'current_phase' names an unknown phase: {value!r}"
147
+ )
148
+ return value
149
+ if isinstance(value, int):
150
+ return _legacy_current_phase_name(value, phases)
151
+ raise ContinuousRefactorError(
152
+ f"Migration field 'current_phase' must be a string or legacy int: {value!r}"
153
+ )
154
+
155
+
156
+ def decode_manifest_payload(raw_payload: object) -> MigrationManifest:
157
+ raw = _require_mapping(raw_payload, field="manifest")
158
+ phases = _require_phases(raw.get("phases"))
159
+ return MigrationManifest(
160
+ name=_require_str(raw.get("name"), field="name"),
161
+ created_at=_require_str(raw.get("created_at"), field="created_at"),
162
+ last_touch=_require_str(raw.get("last_touch"), field="last_touch"),
163
+ wake_up_on=_optional_str(raw.get("wake_up_on"), field="wake_up_on"),
164
+ awaiting_human_review=_require_bool(
165
+ raw.get("awaiting_human_review", False), field="awaiting_human_review"
166
+ ),
167
+ status=_require_status(raw.get("status")),
168
+ current_phase=_require_current_phase(
169
+ raw.get("current_phase"), phases=phases,
170
+ ),
171
+ phases=phases,
172
+ human_review_reason=_optional_str(
173
+ raw.get("human_review_reason"), field="human_review_reason"
174
+ ),
175
+ cooldown_until=_optional_str(
176
+ raw.get("cooldown_until"), field="cooldown_until"
177
+ ),
178
+ )
179
+
180
+
181
+ def encode_manifest_payload(manifest: MigrationManifest) -> str:
182
+ _require_status(manifest.status)
183
+ _require_unique_phase_names(manifest.phases)
184
+ if manifest.current_phase and not any(
185
+ phase.name == manifest.current_phase for phase in manifest.phases
186
+ ):
187
+ raise ContinuousRefactorError(
188
+ f"Cannot save manifest with unknown current phase {manifest.current_phase!r}"
189
+ )
190
+ return json.dumps(asdict(manifest), indent=2, sort_keys=True) + "\n"
@@ -0,0 +1,468 @@
1
+ """Migration tick orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Protocol
9
+
10
+ if TYPE_CHECKING:
11
+ from continuous_refactoring.artifacts import RunArtifacts
12
+ from continuous_refactoring.migrations import MigrationManifest, PhaseSpec
13
+ from continuous_refactoring.phases import ExecutePhaseOutcome
14
+
15
+ __all__ = [
16
+ "enumerate_eligible_manifests",
17
+ "try_migration_tick",
18
+ ]
19
+
20
+ from continuous_refactoring.artifacts import ContinuousRefactorError
21
+ from continuous_refactoring.commit_messages import build_commit_message
22
+ from continuous_refactoring.decisions import (
23
+ DecisionRecord,
24
+ RouteOutcome,
25
+ error_failure_kind,
26
+ sanitize_text,
27
+ )
28
+ from continuous_refactoring.effort import (
29
+ EffortBudget,
30
+ effort_exceeds,
31
+ resolve_effort_budget,
32
+ resolve_phase_effort,
33
+ )
34
+ from continuous_refactoring.git import get_head_sha
35
+ from continuous_refactoring.migrations import (
36
+ bump_last_touch,
37
+ eligible_now,
38
+ has_executable_phase,
39
+ load_manifest,
40
+ phase_file_reference,
41
+ resolve_current_phase,
42
+ save_manifest,
43
+ )
44
+ from continuous_refactoring.phases import (
45
+ ReadyVerdict,
46
+ check_phase_ready,
47
+ execute_phase,
48
+ )
49
+
50
+
51
+ _BASELINE_VALIDATION_UNCERTAINTY_PHRASES = (
52
+ "baseline green",
53
+ "baseline validation",
54
+ "current tests pass",
55
+ "fresh test evidence",
56
+ "fresh validation evidence",
57
+ "full test suite passes",
58
+ "tests pass now",
59
+ )
60
+
61
+
62
+ class _FinalizeCommit(Protocol):
63
+ def __call__(
64
+ self,
65
+ repo_root: Path,
66
+ head_before: str,
67
+ commit_message: str,
68
+ *,
69
+ artifacts: RunArtifacts,
70
+ attempt: int,
71
+ phase: str,
72
+ ) -> str | None:
73
+ ...
74
+
75
+
76
+ def enumerate_eligible_manifests(
77
+ live_dir: Path,
78
+ now: datetime,
79
+ effort_budget: EffortBudget | None = None,
80
+ ) -> list[tuple[MigrationManifest, Path]]:
81
+ if not live_dir.is_dir():
82
+ return []
83
+ candidates: list[tuple[MigrationManifest, Path]] = []
84
+ for entry in sorted(live_dir.iterdir()):
85
+ if not entry.is_dir() or entry.name.startswith("__"):
86
+ continue
87
+ manifest_path = entry / "manifest.json"
88
+ if not manifest_path.exists():
89
+ continue
90
+ manifest = load_manifest(manifest_path)
91
+ if manifest.status not in ("ready", "in-progress"):
92
+ continue
93
+ if manifest.awaiting_human_review:
94
+ continue
95
+ if not has_executable_phase(manifest):
96
+ continue
97
+ if not eligible_now(manifest, now):
98
+ continue
99
+ candidates.append((manifest, manifest_path))
100
+ if effort_budget is not None:
101
+ seen_paths = {path for _, path in candidates}
102
+ for manifest, manifest_path in _cooling_effort_candidates(
103
+ live_dir, now, effort_budget,
104
+ ):
105
+ if manifest_path not in seen_paths:
106
+ candidates.append((manifest, manifest_path))
107
+ candidates.sort(key=lambda pair: datetime.fromisoformat(pair[0].created_at))
108
+ return candidates
109
+
110
+
111
+ def _cooling_effort_candidates(
112
+ live_dir: Path,
113
+ now: datetime,
114
+ budget: EffortBudget,
115
+ ) -> list[tuple[MigrationManifest, Path]]:
116
+ if not live_dir.is_dir():
117
+ return []
118
+ candidates: list[tuple[MigrationManifest, Path]] = []
119
+ for entry in sorted(live_dir.iterdir()):
120
+ if not entry.is_dir() or entry.name.startswith("__"):
121
+ continue
122
+ manifest_path = entry / "manifest.json"
123
+ if not manifest_path.exists():
124
+ continue
125
+ manifest = load_manifest(manifest_path)
126
+ if not _can_ignore_effort_cooldown(manifest, now, budget):
127
+ continue
128
+ candidates.append((manifest, manifest_path))
129
+ return candidates
130
+
131
+
132
+ def _can_ignore_effort_cooldown(
133
+ manifest: MigrationManifest,
134
+ now: datetime,
135
+ budget: EffortBudget,
136
+ ) -> bool:
137
+ if manifest.status not in ("ready", "in-progress"):
138
+ return False
139
+ if manifest.awaiting_human_review or not has_executable_phase(manifest):
140
+ return False
141
+ if manifest.cooldown_until is None:
142
+ return False
143
+ if datetime.fromisoformat(manifest.cooldown_until) <= now:
144
+ return False
145
+ phase = resolve_current_phase(manifest)
146
+ return (
147
+ phase.required_effort is not None
148
+ and not effort_exceeds(phase.required_effort, budget.max_allowed_effort)
149
+ )
150
+
151
+
152
+ def try_migration_tick(
153
+ live_dir: Path,
154
+ taste: str,
155
+ repo_root: Path,
156
+ artifacts: RunArtifacts,
157
+ *,
158
+ agent: str,
159
+ model: str,
160
+ effort: str,
161
+ timeout: int | None,
162
+ commit_message_prefix: str,
163
+ validation_command: str,
164
+ max_attempts: int | None,
165
+ attempt: int,
166
+ finalize_commit: _FinalizeCommit,
167
+ effort_budget: EffortBudget | None = None,
168
+ ) -> tuple[RouteOutcome, DecisionRecord | None]:
169
+ resolved_budget = effort_budget or resolve_effort_budget(effort, None)
170
+ now = datetime.now(timezone.utc)
171
+ candidates = enumerate_eligible_manifests(live_dir, now, resolved_budget)
172
+ deferred_record: DecisionRecord | None = None
173
+ pending_defers: list[tuple[MigrationManifest, Path]] = []
174
+
175
+ for manifest, manifest_path in candidates:
176
+ phase = resolve_current_phase(manifest)
177
+ target_label = _target_label(manifest, phase)
178
+ if (
179
+ phase.required_effort is not None
180
+ and effort_exceeds(
181
+ phase.required_effort,
182
+ resolved_budget.max_allowed_effort,
183
+ )
184
+ ):
185
+ reason = _effort_defer_reason(
186
+ phase,
187
+ max_allowed_effort=resolved_budget.max_allowed_effort,
188
+ )
189
+ _log_phase_effort_deferred(
190
+ artifacts,
191
+ target_label,
192
+ phase,
193
+ reason,
194
+ max_allowed_effort=resolved_budget.max_allowed_effort,
195
+ )
196
+ pending_defers.append(
197
+ (
198
+ _defer_manifest(
199
+ manifest,
200
+ now,
201
+ verdict="effort-over-budget",
202
+ reason=reason,
203
+ ),
204
+ manifest_path,
205
+ )
206
+ )
207
+ deferred_record = _effort_deferred_record(reason, repo_root, target_label)
208
+ continue
209
+
210
+ phase_resolution = resolve_phase_effort(
211
+ resolved_budget,
212
+ phase.required_effort,
213
+ reason=phase.effort_reason,
214
+ )
215
+ phase_effort = phase_resolution.effective_effort
216
+ effort_metadata = phase_resolution.event_fields()
217
+ try:
218
+ verdict, reason = check_phase_ready(
219
+ phase,
220
+ manifest,
221
+ repo_root,
222
+ artifacts,
223
+ taste=taste,
224
+ attempt=attempt,
225
+ retry=1,
226
+ agent=agent,
227
+ model=model,
228
+ effort=phase_effort,
229
+ effort_metadata=effort_metadata,
230
+ timeout=timeout,
231
+ )
232
+ except ContinuousRefactorError as error:
233
+ return "abandon", _ready_check_failure_record(error, repo_root, target_label)
234
+
235
+ verdict, reason = _normalize_ready_verdict(verdict, reason)
236
+
237
+ if verdict == "yes":
238
+ head_before = get_head_sha(repo_root)
239
+ outcome = execute_phase(
240
+ phase,
241
+ manifest,
242
+ taste,
243
+ repo_root,
244
+ live_dir,
245
+ artifacts,
246
+ attempt=attempt,
247
+ retry=1,
248
+ agent=agent,
249
+ model=model,
250
+ effort=phase_effort,
251
+ effort_metadata=effort_metadata,
252
+ timeout=timeout,
253
+ validation_command=validation_command,
254
+ max_attempts=max_attempts,
255
+ )
256
+
257
+ if outcome.status != "failed":
258
+ finalize_commit(
259
+ repo_root,
260
+ head_before,
261
+ build_commit_message(
262
+ f"{commit_message_prefix}: migration/{manifest.name}"
263
+ f"/{phase_file_reference(phase)}",
264
+ why=sanitize_text(outcome.reason, repo_root) or outcome.reason,
265
+ validation=validation_command,
266
+ ),
267
+ artifacts=artifacts,
268
+ attempt=attempt,
269
+ phase="migration",
270
+ )
271
+
272
+ print(
273
+ f"Migration: {outcome.status}"
274
+ f" — {manifest.name} {phase_file_reference(phase)} ({phase.name})"
275
+ )
276
+ if outcome.status == "failed":
277
+ return "abandon", _phase_failure_record(outcome, repo_root, target_label)
278
+ return "commit", _phase_commit_record(outcome, repo_root, target_label)
279
+
280
+ pending_defers.append(
281
+ (
282
+ _defer_manifest(manifest, now, verdict=verdict, reason=reason),
283
+ manifest_path,
284
+ )
285
+ )
286
+ if verdict == "unverifiable":
287
+ _save_pending_defers(pending_defers)
288
+ return "blocked", _human_review_record(reason, repo_root, target_label)
289
+ deferred_record = _deferred_record(reason, repo_root, target_label)
290
+
291
+ _save_pending_defers(pending_defers)
292
+ return "not-routed", deferred_record
293
+
294
+
295
+ def _target_label(manifest: MigrationManifest, phase: PhaseSpec) -> str:
296
+ return f"{manifest.name} {phase_file_reference(phase)} ({phase.name})"
297
+
298
+
299
+ def _normalize_ready_verdict(
300
+ verdict: ReadyVerdict,
301
+ reason: str,
302
+ ) -> tuple[ReadyVerdict, str]:
303
+ if verdict != "unverifiable":
304
+ return verdict, reason
305
+ if not _is_baseline_validation_uncertainty(reason):
306
+ return verdict, reason
307
+ return "no", reason
308
+
309
+
310
+ def _is_baseline_validation_uncertainty(reason: str) -> bool:
311
+ reason_lower = reason.lower()
312
+ return any(
313
+ phrase in reason_lower
314
+ for phrase in _BASELINE_VALIDATION_UNCERTAINTY_PHRASES
315
+ )
316
+
317
+
318
+ def _save_pending_defers(
319
+ pending_defers: list[tuple[MigrationManifest, Path]],
320
+ ) -> None:
321
+ for deferred_manifest, manifest_path in pending_defers:
322
+ save_manifest(deferred_manifest, manifest_path)
323
+
324
+
325
+ def _effort_defer_reason(
326
+ phase: PhaseSpec,
327
+ *,
328
+ max_allowed_effort: str,
329
+ ) -> str:
330
+ detail = (
331
+ f" Reason: {phase.effort_reason}."
332
+ if phase.effort_reason
333
+ else ""
334
+ )
335
+ return (
336
+ f"Phase requires {phase.required_effort} effort, above this run's "
337
+ f"max allowed effort {max_allowed_effort}.{detail}"
338
+ )
339
+
340
+
341
+ def _log_phase_effort_deferred(
342
+ artifacts: RunArtifacts,
343
+ target_label: str,
344
+ phase: PhaseSpec,
345
+ reason: str,
346
+ *,
347
+ max_allowed_effort: str,
348
+ ) -> None:
349
+ artifacts.log(
350
+ "INFO",
351
+ f"phase effort deferred: {target_label}",
352
+ event="phase_effort_deferred",
353
+ target=target_label,
354
+ required_effort=phase.required_effort,
355
+ max_allowed_effort=max_allowed_effort,
356
+ effort_reason=phase.effort_reason,
357
+ summary=reason,
358
+ )
359
+
360
+
361
+ def _ready_check_failure_record(
362
+ error: ContinuousRefactorError, repo_root: Path, target_label: str,
363
+ ) -> DecisionRecord:
364
+ summary = sanitize_text(str(error), repo_root) or str(error)
365
+ return DecisionRecord(
366
+ decision="abandon",
367
+ retry_recommendation="new-target",
368
+ target=target_label,
369
+ call_role="phase.ready-check",
370
+ phase_reached="phase.ready-check",
371
+ failure_kind=error_failure_kind(str(error)),
372
+ summary=summary,
373
+ )
374
+
375
+
376
+ def _phase_failure_record(
377
+ outcome: ExecutePhaseOutcome, repo_root: Path, target_label: str,
378
+ ) -> DecisionRecord:
379
+ return DecisionRecord(
380
+ decision="abandon",
381
+ retry_recommendation="new-target",
382
+ target=target_label,
383
+ call_role=outcome.call_role or "phase.execute",
384
+ phase_reached=outcome.phase_reached or "phase.execute",
385
+ failure_kind=outcome.failure_kind or "phase-failed",
386
+ summary=sanitize_text(outcome.reason, repo_root) or outcome.reason,
387
+ retry_used=outcome.retry,
388
+ )
389
+
390
+
391
+ def _phase_commit_record(
392
+ outcome: ExecutePhaseOutcome,
393
+ repo_root: Path,
394
+ target_label: str,
395
+ ) -> DecisionRecord:
396
+ return DecisionRecord(
397
+ decision="commit",
398
+ retry_recommendation="none",
399
+ target=target_label,
400
+ call_role="phase.execute",
401
+ phase_reached="phase.execute",
402
+ failure_kind="none",
403
+ summary=sanitize_text(outcome.reason, repo_root) or outcome.reason,
404
+ )
405
+
406
+
407
+ def _defer_manifest(
408
+ manifest: MigrationManifest,
409
+ now: datetime,
410
+ *,
411
+ verdict: str,
412
+ reason: str,
413
+ ) -> MigrationManifest:
414
+ updated = replace(
415
+ bump_last_touch(manifest, now),
416
+ cooldown_until=(now + timedelta(hours=6)).isoformat(timespec="milliseconds"),
417
+ )
418
+ if updated.wake_up_on is None:
419
+ wake = (now + timedelta(days=7)).isoformat(timespec="milliseconds")
420
+ updated = replace(updated, wake_up_on=wake)
421
+ if verdict == "unverifiable":
422
+ updated = replace(
423
+ updated,
424
+ awaiting_human_review=True,
425
+ human_review_reason=reason,
426
+ )
427
+ return updated
428
+
429
+
430
+ def _human_review_record(
431
+ reason: str, repo_root: Path, target_label: str,
432
+ ) -> DecisionRecord:
433
+ summary = sanitize_text(reason, repo_root) or "Phase requires human review"
434
+ return DecisionRecord(
435
+ decision="blocked",
436
+ retry_recommendation="human-review",
437
+ target=target_label,
438
+ call_role="phase.ready-check",
439
+ phase_reached="phase.ready-check",
440
+ failure_kind="phase-ready-unverifiable",
441
+ summary=summary,
442
+ )
443
+
444
+
445
+ def _deferred_record(reason: str, repo_root: Path, target_label: str) -> DecisionRecord:
446
+ return DecisionRecord(
447
+ decision="retry",
448
+ retry_recommendation="same-target",
449
+ target=target_label,
450
+ call_role="phase.ready-check",
451
+ phase_reached="phase.ready-check",
452
+ failure_kind="phase-ready-no",
453
+ summary=sanitize_text(reason, repo_root) or "Migration phase not ready",
454
+ )
455
+
456
+
457
+ def _effort_deferred_record(
458
+ reason: str, repo_root: Path, target_label: str,
459
+ ) -> DecisionRecord:
460
+ return DecisionRecord(
461
+ decision="retry",
462
+ retry_recommendation="same-target",
463
+ target=target_label,
464
+ call_role="phase.effort-budget",
465
+ phase_reached="phase.effort-budget",
466
+ failure_kind="phase-effort-over-budget",
467
+ summary=sanitize_text(reason, repo_root) or "Migration phase over effort budget",
468
+ )