ctrlrelay 0.1.5__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.
- ctrlrelay/__init__.py +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Core functionality for ctrlrelay orchestrator."""
|
|
2
|
+
|
|
3
|
+
from ctrlrelay.core.checkpoint import (
|
|
4
|
+
CheckpointError,
|
|
5
|
+
CheckpointState,
|
|
6
|
+
CheckpointStatus,
|
|
7
|
+
blocked,
|
|
8
|
+
done,
|
|
9
|
+
failed,
|
|
10
|
+
read_checkpoint,
|
|
11
|
+
)
|
|
12
|
+
from ctrlrelay.core.config import (
|
|
13
|
+
Config,
|
|
14
|
+
ConfigError,
|
|
15
|
+
RepoConfig,
|
|
16
|
+
load_config,
|
|
17
|
+
)
|
|
18
|
+
from ctrlrelay.core.dispatcher import (
|
|
19
|
+
AgentAdapter,
|
|
20
|
+
ClaudeDispatcher,
|
|
21
|
+
SessionResult,
|
|
22
|
+
make_agent_dispatcher,
|
|
23
|
+
)
|
|
24
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
25
|
+
from ctrlrelay.core.poller import IssuePoller
|
|
26
|
+
from ctrlrelay.core.pr_verifier import PRVerifier, VerificationResult
|
|
27
|
+
from ctrlrelay.core.pr_watcher import PRWatcher
|
|
28
|
+
from ctrlrelay.core.state import StateDB
|
|
29
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CheckpointError",
|
|
33
|
+
"CheckpointState",
|
|
34
|
+
"CheckpointStatus",
|
|
35
|
+
"AgentAdapter",
|
|
36
|
+
"ClaudeDispatcher",
|
|
37
|
+
"make_agent_dispatcher",
|
|
38
|
+
"Config",
|
|
39
|
+
"ConfigError",
|
|
40
|
+
"GitHubCLI",
|
|
41
|
+
"IssuePoller",
|
|
42
|
+
"PRVerifier",
|
|
43
|
+
"PRWatcher",
|
|
44
|
+
"RepoConfig",
|
|
45
|
+
"SessionResult",
|
|
46
|
+
"StateDB",
|
|
47
|
+
"VerificationResult",
|
|
48
|
+
"WorktreeManager",
|
|
49
|
+
"blocked",
|
|
50
|
+
"done",
|
|
51
|
+
"failed",
|
|
52
|
+
"load_config",
|
|
53
|
+
"read_checkpoint",
|
|
54
|
+
]
|
ctrlrelay/core/audit.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Skill audit functionality for orchestrator readiness checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuditCheck(str, Enum):
|
|
14
|
+
"""Types of orchestrator readiness checks."""
|
|
15
|
+
|
|
16
|
+
CHECKPOINT = "checkpoint"
|
|
17
|
+
HEADLESS = "headless"
|
|
18
|
+
CONTEXT_PATH = "context_path"
|
|
19
|
+
ATTRIBUTION = "attribution"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AuditResult:
|
|
24
|
+
"""Result of a single audit check."""
|
|
25
|
+
|
|
26
|
+
passed: bool
|
|
27
|
+
reason: str = ""
|
|
28
|
+
auto_fixable: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SkillAudit:
|
|
33
|
+
"""Audit results for a single skill."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
path: Path
|
|
37
|
+
results: dict[AuditCheck, AuditResult] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def passed(self) -> bool:
|
|
41
|
+
"""True if all checks passed."""
|
|
42
|
+
return all(r.passed for r in self.results.values())
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def status(self) -> str:
|
|
46
|
+
"""Human-readable status."""
|
|
47
|
+
return "READY" if self.passed else "NOT READY"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SkillInfo:
|
|
52
|
+
"""Basic skill information from SKILL.md."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
path: Path
|
|
56
|
+
content: str
|
|
57
|
+
frontmatter: dict
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Patterns for headless check
|
|
61
|
+
INTERACTIVE_PATTERNS = [
|
|
62
|
+
r"\binput\s*\(",
|
|
63
|
+
r"\bread\s+-p\b",
|
|
64
|
+
r"Confirm\s*\(",
|
|
65
|
+
r"typer\.confirm\s*\(",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
BROWSER_ONLY_TOOLS = [
|
|
69
|
+
"mcp__playwright__",
|
|
70
|
+
"mcp__chrome_devtools__",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Patterns for checkpoint check
|
|
74
|
+
CHECKPOINT_PATTERNS = [
|
|
75
|
+
r"from\s+ctrlrelay\s+import\s+checkpoint",
|
|
76
|
+
r"from\s+ctrlrelay\.core\.checkpoint\s+import",
|
|
77
|
+
r"checkpoint\.(done|blocked|failed)\s*\(",
|
|
78
|
+
r"CTRLRELAY_STATE_FILE",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
# Attribution patterns to avoid in output
|
|
82
|
+
ATTRIBUTION_PATTERNS = [
|
|
83
|
+
r"\bClaude\b",
|
|
84
|
+
r"\bAnthropic\b",
|
|
85
|
+
r"Generated by AI",
|
|
86
|
+
r"AI Assistant",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_check(skill: SkillInfo, check: AuditCheck) -> AuditResult:
|
|
91
|
+
"""Run a single audit check on a skill."""
|
|
92
|
+
content = skill.content
|
|
93
|
+
tools = skill.frontmatter.get("tools", "")
|
|
94
|
+
|
|
95
|
+
if check == AuditCheck.CHECKPOINT:
|
|
96
|
+
for pattern in CHECKPOINT_PATTERNS:
|
|
97
|
+
if re.search(pattern, content):
|
|
98
|
+
return AuditResult(passed=True)
|
|
99
|
+
return AuditResult(passed=False, reason="No checkpoint protocol usage found")
|
|
100
|
+
|
|
101
|
+
if check == AuditCheck.HEADLESS:
|
|
102
|
+
for pattern in INTERACTIVE_PATTERNS:
|
|
103
|
+
if re.search(pattern, content):
|
|
104
|
+
reason = f"Interactive prompt pattern found: {pattern}"
|
|
105
|
+
return AuditResult(passed=False, reason=reason)
|
|
106
|
+
for tool in BROWSER_ONLY_TOOLS:
|
|
107
|
+
if tool in content or tool in tools:
|
|
108
|
+
if "fallback" not in content.lower() and "cli" not in content.lower():
|
|
109
|
+
reason = f"Browser-only tool without fallback: {tool}"
|
|
110
|
+
return AuditResult(passed=False, reason=reason)
|
|
111
|
+
return AuditResult(passed=True)
|
|
112
|
+
|
|
113
|
+
if check == AuditCheck.CONTEXT_PATH:
|
|
114
|
+
if "REPO_CONTEXT_PATH" in content or "$REPO_CONTEXT_PATH" in content:
|
|
115
|
+
return AuditResult(passed=True)
|
|
116
|
+
if "context" in content.lower() and "/" in content:
|
|
117
|
+
return AuditResult(
|
|
118
|
+
passed=False, reason="May use hardcoded context path", auto_fixable=True
|
|
119
|
+
)
|
|
120
|
+
return AuditResult(passed=True)
|
|
121
|
+
|
|
122
|
+
if check == AuditCheck.ATTRIBUTION:
|
|
123
|
+
for pattern in ATTRIBUTION_PATTERNS:
|
|
124
|
+
match = re.search(pattern, content, re.IGNORECASE)
|
|
125
|
+
if match:
|
|
126
|
+
reason = f"Attribution pattern found: {match.group()}"
|
|
127
|
+
return AuditResult(passed=False, reason=reason, auto_fixable=True)
|
|
128
|
+
return AuditResult(passed=True)
|
|
129
|
+
|
|
130
|
+
return AuditResult(passed=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def discover_skills(skills_dir: Path) -> list[SkillInfo]:
|
|
134
|
+
"""Discover all skills in a directory.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
skills_dir: Path to skills directory.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of SkillInfo for each skill found.
|
|
141
|
+
"""
|
|
142
|
+
skills = []
|
|
143
|
+
|
|
144
|
+
if not skills_dir.exists():
|
|
145
|
+
return skills
|
|
146
|
+
|
|
147
|
+
for skill_md in skills_dir.glob("*/SKILL.md"):
|
|
148
|
+
content = skill_md.read_text()
|
|
149
|
+
|
|
150
|
+
# Parse YAML frontmatter
|
|
151
|
+
frontmatter = {}
|
|
152
|
+
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
153
|
+
if match:
|
|
154
|
+
try:
|
|
155
|
+
frontmatter = yaml.safe_load(match.group(1)) or {}
|
|
156
|
+
except yaml.YAMLError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
name = frontmatter.get("name", skill_md.parent.name)
|
|
160
|
+
skills.append(
|
|
161
|
+
SkillInfo(
|
|
162
|
+
name=name,
|
|
163
|
+
path=skill_md.parent,
|
|
164
|
+
content=content,
|
|
165
|
+
frontmatter=frontmatter,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return sorted(skills, key=lambda s: s.name)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def audit_skill(skill: SkillInfo) -> SkillAudit:
|
|
173
|
+
"""Run all audit checks on a skill.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
skill: Skill information.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
SkillAudit with all check results.
|
|
180
|
+
"""
|
|
181
|
+
results = {}
|
|
182
|
+
for check in AuditCheck:
|
|
183
|
+
results[check] = run_check(skill, check)
|
|
184
|
+
|
|
185
|
+
return SkillAudit(
|
|
186
|
+
name=skill.name,
|
|
187
|
+
path=skill.path,
|
|
188
|
+
results=results,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def audit_all(skills_dir: Path) -> list[SkillAudit]:
|
|
193
|
+
"""Audit all skills in a directory.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
skills_dir: Path to skills directory.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of SkillAudit results.
|
|
200
|
+
"""
|
|
201
|
+
skills = discover_skills(skills_dir)
|
|
202
|
+
return [audit_skill(skill) for skill in skills]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_report(audits: list[SkillAudit]) -> str:
|
|
206
|
+
"""Format audit results as markdown report.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
audits: List of skill audit results.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Markdown formatted report.
|
|
213
|
+
"""
|
|
214
|
+
lines = [
|
|
215
|
+
"## Skill Audit Report",
|
|
216
|
+
"",
|
|
217
|
+
"| Skill | Checkpoint | Headless | Context | Attribution | Status |",
|
|
218
|
+
"|-------|------------|----------|---------|-------------|--------|",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
for audit in audits:
|
|
222
|
+
def icon(check: AuditCheck) -> str:
|
|
223
|
+
result = audit.results.get(check)
|
|
224
|
+
if result is None:
|
|
225
|
+
return "➖"
|
|
226
|
+
return "✅" if result.passed else "❌"
|
|
227
|
+
|
|
228
|
+
lines.append(
|
|
229
|
+
f"| {audit.name} "
|
|
230
|
+
f"| {icon(AuditCheck.CHECKPOINT)} "
|
|
231
|
+
f"| {icon(AuditCheck.HEADLESS)} "
|
|
232
|
+
f"| {icon(AuditCheck.CONTEXT_PATH)} "
|
|
233
|
+
f"| {icon(AuditCheck.ATTRIBUTION)} "
|
|
234
|
+
f"| {audit.status} |"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Summary
|
|
238
|
+
ready = sum(1 for a in audits if a.passed)
|
|
239
|
+
total = len(audits)
|
|
240
|
+
lines.extend([
|
|
241
|
+
"",
|
|
242
|
+
f"**Summary:** {ready}/{total} skills ready for orchestrator",
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
# Details for failed checks
|
|
246
|
+
failed_audits = [a for a in audits if not a.passed]
|
|
247
|
+
if failed_audits:
|
|
248
|
+
lines.extend(["", "### Issues", ""])
|
|
249
|
+
for audit in failed_audits:
|
|
250
|
+
lines.append(f"**{audit.name}:**")
|
|
251
|
+
for check, result in audit.results.items():
|
|
252
|
+
if not result.passed:
|
|
253
|
+
fixable = " (auto-fixable)" if result.auto_fixable else ""
|
|
254
|
+
lines.append(f"- {check.value}: {result.reason}{fixable}")
|
|
255
|
+
lines.append("")
|
|
256
|
+
|
|
257
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Checkpoint protocol for skill-orchestrator communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, model_validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CheckpointStatus(str, Enum):
|
|
16
|
+
"""Status values for checkpoint state."""
|
|
17
|
+
|
|
18
|
+
DONE = "DONE"
|
|
19
|
+
BLOCKED_NEEDS_INPUT = "BLOCKED_NEEDS_INPUT"
|
|
20
|
+
FAILED = "FAILED"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CheckpointState(BaseModel):
|
|
24
|
+
"""State written by skills to communicate with orchestrator."""
|
|
25
|
+
|
|
26
|
+
version: str = "1"
|
|
27
|
+
status: CheckpointStatus
|
|
28
|
+
session_id: str
|
|
29
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
30
|
+
|
|
31
|
+
summary: str | None = None
|
|
32
|
+
|
|
33
|
+
question: str | None = None
|
|
34
|
+
question_context: dict[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
error: str | None = None
|
|
37
|
+
recoverable: bool = True
|
|
38
|
+
|
|
39
|
+
outputs: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="after")
|
|
42
|
+
def validate_status_fields(self) -> "CheckpointState":
|
|
43
|
+
"""Validate that status-dependent fields are present."""
|
|
44
|
+
if self.status == CheckpointStatus.BLOCKED_NEEDS_INPUT and not self.question:
|
|
45
|
+
raise ValueError("question is required when status is BLOCKED_NEEDS_INPUT")
|
|
46
|
+
if self.status == CheckpointStatus.FAILED and not self.error:
|
|
47
|
+
raise ValueError("error is required when status is FAILED")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CheckpointError(Exception):
|
|
52
|
+
"""Raised when checkpoint operations fail."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_state_file() -> Path:
|
|
56
|
+
"""Get state file path from environment."""
|
|
57
|
+
path = os.environ.get("CTRLRELAY_STATE_FILE")
|
|
58
|
+
if not path:
|
|
59
|
+
raise CheckpointError("CTRLRELAY_STATE_FILE environment variable not set")
|
|
60
|
+
return Path(path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_session_id() -> str:
|
|
64
|
+
"""Get session ID from environment."""
|
|
65
|
+
session_id = os.environ.get("CTRLRELAY_SESSION_ID")
|
|
66
|
+
if not session_id:
|
|
67
|
+
raise CheckpointError("CTRLRELAY_SESSION_ID environment variable not set")
|
|
68
|
+
return session_id
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_checkpoint(state: CheckpointState) -> None:
|
|
72
|
+
"""Write checkpoint state atomically."""
|
|
73
|
+
state_file = _get_state_file()
|
|
74
|
+
temp_file = state_file.with_suffix(".json.tmp")
|
|
75
|
+
|
|
76
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
# Write to temp file first
|
|
79
|
+
temp_file.write_text(state.model_dump_json(indent=2))
|
|
80
|
+
|
|
81
|
+
# Atomic rename
|
|
82
|
+
temp_file.rename(state_file)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def done(summary: str, outputs: dict[str, Any] | None = None) -> None:
|
|
86
|
+
"""Report successful completion."""
|
|
87
|
+
# Check state file first so error message is consistent
|
|
88
|
+
_get_state_file()
|
|
89
|
+
state = CheckpointState(
|
|
90
|
+
status=CheckpointStatus.DONE,
|
|
91
|
+
session_id=_get_session_id(),
|
|
92
|
+
summary=summary,
|
|
93
|
+
outputs=outputs or {},
|
|
94
|
+
)
|
|
95
|
+
_write_checkpoint(state)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def blocked(question: str, context: dict[str, Any] | None = None) -> None:
|
|
99
|
+
"""Report blocked on human input."""
|
|
100
|
+
# Check state file first so error message is consistent
|
|
101
|
+
_get_state_file()
|
|
102
|
+
state = CheckpointState(
|
|
103
|
+
status=CheckpointStatus.BLOCKED_NEEDS_INPUT,
|
|
104
|
+
session_id=_get_session_id(),
|
|
105
|
+
question=question,
|
|
106
|
+
question_context=context,
|
|
107
|
+
)
|
|
108
|
+
_write_checkpoint(state)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def failed(error: str, recoverable: bool = True) -> None:
|
|
112
|
+
"""Report failure."""
|
|
113
|
+
# Check state file first so error message is consistent
|
|
114
|
+
_get_state_file()
|
|
115
|
+
state = CheckpointState(
|
|
116
|
+
status=CheckpointStatus.FAILED,
|
|
117
|
+
session_id=_get_session_id(),
|
|
118
|
+
error=error,
|
|
119
|
+
recoverable=recoverable,
|
|
120
|
+
)
|
|
121
|
+
_write_checkpoint(state)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_checkpoint(path: Path, delete_after: bool = False) -> CheckpointState:
|
|
125
|
+
"""Read and parse a checkpoint file.
|
|
126
|
+
|
|
127
|
+
Used by the orchestrator to read skill results.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
path: Path to the checkpoint file.
|
|
131
|
+
delete_after: If True, delete the file after reading.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Parsed CheckpointState.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
CheckpointError: If file not found or invalid.
|
|
138
|
+
"""
|
|
139
|
+
if not path.exists():
|
|
140
|
+
raise CheckpointError(f"Checkpoint file not found: {path}")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(path.read_text())
|
|
144
|
+
except json.JSONDecodeError as e:
|
|
145
|
+
raise CheckpointError(f"Failed to parse checkpoint file: {e}") from e
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
state = CheckpointState.model_validate(data)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise CheckpointError(f"Invalid checkpoint data: {e}") from e
|
|
151
|
+
|
|
152
|
+
if delete_after:
|
|
153
|
+
path.unlink()
|
|
154
|
+
|
|
155
|
+
return state
|