pruv 1.0.0__tar.gz
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.
- pruv-1.0.0/LICENSE +21 -0
- pruv-1.0.0/PKG-INFO +75 -0
- pruv-1.0.0/README.md +26 -0
- pruv-1.0.0/pruv/__init__.py +45 -0
- pruv-1.0.0/pruv/approval/__init__.py +5 -0
- pruv-1.0.0/pruv/approval/gate.py +117 -0
- pruv-1.0.0/pruv/checkpoint/__init__.py +5 -0
- pruv-1.0.0/pruv/checkpoint/manager.py +223 -0
- pruv-1.0.0/pruv/cli/__init__.py +5 -0
- pruv-1.0.0/pruv/cli/commands.py +186 -0
- pruv-1.0.0/pruv/cloud/__init__.py +5 -0
- pruv-1.0.0/pruv/cloud/client.py +228 -0
- pruv-1.0.0/pruv/cloud/queue.py +155 -0
- pruv-1.0.0/pruv/graph/__init__.py +5 -0
- pruv-1.0.0/pruv/graph/graph.py +169 -0
- pruv-1.0.0/pruv/graph/visualize.py +154 -0
- pruv-1.0.0/pruv/scanner/__init__.py +5 -0
- pruv-1.0.0/pruv/scanner/patterns.py +242 -0
- pruv-1.0.0/pruv/scanner/scanner.py +331 -0
- pruv-1.0.0/pruv/wrap/__init__.py +6 -0
- pruv-1.0.0/pruv/wrap/observers.py +190 -0
- pruv-1.0.0/pruv/wrap/wrapper.py +452 -0
- pruv-1.0.0/pruv.egg-info/PKG-INFO +75 -0
- pruv-1.0.0/pruv.egg-info/SOURCES.txt +31 -0
- pruv-1.0.0/pruv.egg-info/dependency_links.txt +1 -0
- pruv-1.0.0/pruv.egg-info/entry_points.txt +2 -0
- pruv-1.0.0/pruv.egg-info/requires.txt +5 -0
- pruv-1.0.0/pruv.egg-info/top_level.txt +1 -0
- pruv-1.0.0/pyproject.toml +41 -0
- pruv-1.0.0/setup.cfg +4 -0
- pruv-1.0.0/tests/test_pruv.py +295 -0
- pruv-1.0.0/tests/test_xy_wrap_integration.py +947 -0
- pruv-1.0.0/tests/test_xy_wrap_scenarios.py +580 -0
pruv-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 pruv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pruv-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pruv
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Prove what happened. Cryptographic verification for any system.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 pruv
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://pruv.dev
|
|
28
|
+
Project-URL: Documentation, https://docs.pruv.dev
|
|
29
|
+
Project-URL: Repository, https://github.com/pruv-dev/pruv
|
|
30
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Topic :: Security :: Cryptography
|
|
39
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: LICENSE
|
|
43
|
+
Requires-Dist: xycore>=1.0.0
|
|
44
|
+
Requires-Dist: httpx>=0.25.0
|
|
45
|
+
Requires-Dist: click>=8.0
|
|
46
|
+
Requires-Dist: pydantic>=2.0
|
|
47
|
+
Requires-Dist: zstandard>=0.21.0
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# pruv
|
|
51
|
+
|
|
52
|
+
Prove what happened. Cryptographic verification for any system.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install pruv
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pruv import xy_wrap
|
|
62
|
+
|
|
63
|
+
wrapped = xy_wrap(my_agent)
|
|
64
|
+
result = await wrapped.run("Fix the bug")
|
|
65
|
+
print(result.receipt.hash)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
- **Scanner**: Scan any project for files, imports, env vars, frameworks, and services
|
|
71
|
+
- **xy_wrap()**: Universal wrapper for any agent, function, or workflow
|
|
72
|
+
- **Checkpoints**: Create snapshots, preview restore diffs, quick undo
|
|
73
|
+
- **Approval Gates**: Webhook-based human approval for high-risk operations
|
|
74
|
+
- **Cloud Sync**: Sync chains to api.pruv.dev
|
|
75
|
+
- **CLI**: `pruv scan`, `pruv verify`, `pruv export`, `pruv undo`, `pruv upload`
|
pruv-1.0.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# pruv
|
|
2
|
+
|
|
3
|
+
Prove what happened. Cryptographic verification for any system.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install pruv
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pruv import xy_wrap
|
|
13
|
+
|
|
14
|
+
wrapped = xy_wrap(my_agent)
|
|
15
|
+
result = await wrapped.run("Fix the bug")
|
|
16
|
+
print(result.receipt.hash)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Scanner**: Scan any project for files, imports, env vars, frameworks, and services
|
|
22
|
+
- **xy_wrap()**: Universal wrapper for any agent, function, or workflow
|
|
23
|
+
- **Checkpoints**: Create snapshots, preview restore diffs, quick undo
|
|
24
|
+
- **Approval Gates**: Webhook-based human approval for high-risk operations
|
|
25
|
+
- **Cloud Sync**: Sync chains to api.pruv.dev
|
|
26
|
+
- **CLI**: `pruv scan`, `pruv verify`, `pruv export`, `pruv undo`, `pruv upload`
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""pruv — Prove what happened. Full SDK with scanner, wrappers, checkpoints, and cloud sync."""
|
|
2
|
+
|
|
3
|
+
# Re-export from xycore
|
|
4
|
+
from xycore import XYEntry, XYChain, XYReceipt, ThinkingPhase
|
|
5
|
+
|
|
6
|
+
# Scanner
|
|
7
|
+
from .scanner import scan
|
|
8
|
+
from .graph import Graph, GraphDiff
|
|
9
|
+
|
|
10
|
+
# Wrapper
|
|
11
|
+
from .wrap import xy_wrap, WrappedResult
|
|
12
|
+
|
|
13
|
+
# Checkpoints
|
|
14
|
+
from .checkpoint import Checkpoint, CheckpointManager
|
|
15
|
+
|
|
16
|
+
# Approval
|
|
17
|
+
from .approval import ApprovalGate
|
|
18
|
+
|
|
19
|
+
# Cloud
|
|
20
|
+
from .cloud import CloudClient, CloudStorage
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# xycore re-exports
|
|
26
|
+
"XYEntry",
|
|
27
|
+
"XYChain",
|
|
28
|
+
"XYReceipt",
|
|
29
|
+
"ThinkingPhase",
|
|
30
|
+
# Scanner
|
|
31
|
+
"scan",
|
|
32
|
+
"Graph",
|
|
33
|
+
"GraphDiff",
|
|
34
|
+
# Wrapper
|
|
35
|
+
"xy_wrap",
|
|
36
|
+
"WrappedResult",
|
|
37
|
+
# Checkpoints
|
|
38
|
+
"Checkpoint",
|
|
39
|
+
"CheckpointManager",
|
|
40
|
+
# Approval
|
|
41
|
+
"ApprovalGate",
|
|
42
|
+
# Cloud
|
|
43
|
+
"CloudClient",
|
|
44
|
+
"CloudStorage",
|
|
45
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Approval gate — webhook-based human approval for high-risk operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ApprovalRequest:
|
|
12
|
+
"""Request sent to the approval webhook."""
|
|
13
|
+
|
|
14
|
+
chain_id: str
|
|
15
|
+
entry_index: int
|
|
16
|
+
operation: str
|
|
17
|
+
x_state: dict[str, Any] | None = None
|
|
18
|
+
proposed_y_state: dict[str, Any] | None = None
|
|
19
|
+
timestamp: float = 0.0
|
|
20
|
+
|
|
21
|
+
def __post_init__(self):
|
|
22
|
+
if self.timestamp == 0.0:
|
|
23
|
+
self.timestamp = time.time()
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"chain_id": self.chain_id,
|
|
28
|
+
"entry_index": self.entry_index,
|
|
29
|
+
"operation": self.operation,
|
|
30
|
+
"x_state": self.x_state,
|
|
31
|
+
"proposed_y_state": self.proposed_y_state,
|
|
32
|
+
"timestamp": self.timestamp,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ApprovalResponse:
|
|
38
|
+
"""Response from the approval webhook."""
|
|
39
|
+
|
|
40
|
+
status: str # approved, denied, timeout
|
|
41
|
+
approved_by: str | None = None
|
|
42
|
+
reason: str | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_approved(self) -> bool:
|
|
46
|
+
return self.status == "approved"
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
d: dict[str, Any] = {"status": self.status}
|
|
50
|
+
if self.approved_by:
|
|
51
|
+
d["approved_by"] = self.approved_by
|
|
52
|
+
if self.reason:
|
|
53
|
+
d["reason"] = self.reason
|
|
54
|
+
return d
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict[str, Any]) -> "ApprovalResponse":
|
|
58
|
+
return cls(
|
|
59
|
+
status=data["status"],
|
|
60
|
+
approved_by=data.get("approved_by"),
|
|
61
|
+
reason=data.get("reason"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ApprovalGate:
|
|
66
|
+
"""Human approval gate via webhook for high-risk operations."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
webhook_url: str,
|
|
71
|
+
timeout: int = 300,
|
|
72
|
+
operations: set[str] | None = None,
|
|
73
|
+
on_timeout: str = "deny",
|
|
74
|
+
) -> None:
|
|
75
|
+
self.webhook_url = webhook_url
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
self.operations = operations or {"file.write", "deploy", "database.migrate"}
|
|
78
|
+
self.on_timeout = on_timeout
|
|
79
|
+
|
|
80
|
+
def requires_approval(self, operation: str) -> bool:
|
|
81
|
+
"""Check if an operation requires approval."""
|
|
82
|
+
return operation in self.operations
|
|
83
|
+
|
|
84
|
+
async def request_approval(self, request: ApprovalRequest) -> ApprovalResponse:
|
|
85
|
+
"""Send an approval request to the webhook."""
|
|
86
|
+
try:
|
|
87
|
+
import httpx
|
|
88
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
89
|
+
resp = await client.post(self.webhook_url, json=request.to_dict())
|
|
90
|
+
if resp.status_code == 200:
|
|
91
|
+
return ApprovalResponse.from_dict(resp.json())
|
|
92
|
+
return ApprovalResponse(status="denied", reason=f"HTTP {resp.status_code}")
|
|
93
|
+
except Exception as e:
|
|
94
|
+
if self.on_timeout == "approve":
|
|
95
|
+
return ApprovalResponse(status="approved", reason="timeout-auto-approved")
|
|
96
|
+
return ApprovalResponse(status="timeout", reason=str(e))
|
|
97
|
+
|
|
98
|
+
async def gate(
|
|
99
|
+
self,
|
|
100
|
+
chain_id: str,
|
|
101
|
+
entry_index: int,
|
|
102
|
+
operation: str,
|
|
103
|
+
x_state: dict[str, Any] | None = None,
|
|
104
|
+
proposed_y_state: dict[str, Any] | None = None,
|
|
105
|
+
) -> ApprovalResponse:
|
|
106
|
+
"""Full gate check — only calls webhook if operation requires approval."""
|
|
107
|
+
if not self.requires_approval(operation):
|
|
108
|
+
return ApprovalResponse(status="approved", reason="no-approval-required")
|
|
109
|
+
|
|
110
|
+
request = ApprovalRequest(
|
|
111
|
+
chain_id=chain_id,
|
|
112
|
+
entry_index=entry_index,
|
|
113
|
+
operation=operation,
|
|
114
|
+
x_state=x_state,
|
|
115
|
+
proposed_y_state=proposed_y_state,
|
|
116
|
+
)
|
|
117
|
+
return await self.request_approval(request)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Checkpoint manager — create, restore, preview, and auto-checkpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from xycore import XYChain
|
|
13
|
+
|
|
14
|
+
from ..graph import Graph, GraphDiff
|
|
15
|
+
from ..scanner import scan as scan_dir
|
|
16
|
+
|
|
17
|
+
# Try zstandard for compression
|
|
18
|
+
_HAS_ZSTD = False
|
|
19
|
+
try:
|
|
20
|
+
import zstandard
|
|
21
|
+
_HAS_ZSTD = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Checkpoint:
|
|
28
|
+
"""A point-in-time snapshot of chain state and optionally project files."""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
name: str
|
|
32
|
+
chain_id: str
|
|
33
|
+
entry_index: int
|
|
34
|
+
created_at: float
|
|
35
|
+
chain_snapshot: dict[str, Any]
|
|
36
|
+
graph_snapshot: dict[str, Any] | None = None
|
|
37
|
+
file_snapshots: dict[str, str] | None = None
|
|
38
|
+
compressed: bool = False
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"id": self.id,
|
|
43
|
+
"name": self.name,
|
|
44
|
+
"chain_id": self.chain_id,
|
|
45
|
+
"entry_index": self.entry_index,
|
|
46
|
+
"created_at": self.created_at,
|
|
47
|
+
"chain_snapshot": self.chain_snapshot,
|
|
48
|
+
"graph_snapshot": self.graph_snapshot,
|
|
49
|
+
"compressed": self.compressed,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict[str, Any]) -> "Checkpoint":
|
|
54
|
+
return cls(
|
|
55
|
+
id=data["id"],
|
|
56
|
+
name=data["name"],
|
|
57
|
+
chain_id=data["chain_id"],
|
|
58
|
+
entry_index=data["entry_index"],
|
|
59
|
+
created_at=data["created_at"],
|
|
60
|
+
chain_snapshot=data["chain_snapshot"],
|
|
61
|
+
graph_snapshot=data.get("graph_snapshot"),
|
|
62
|
+
compressed=data.get("compressed", False),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RestorePreview:
|
|
68
|
+
"""Preview of what will change if a checkpoint is restored."""
|
|
69
|
+
|
|
70
|
+
checkpoint_id: str
|
|
71
|
+
checkpoint_name: str
|
|
72
|
+
current_entry_index: int
|
|
73
|
+
target_entry_index: int
|
|
74
|
+
entries_to_rollback: int
|
|
75
|
+
diff: GraphDiff | None = None
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
d: dict[str, Any] = {
|
|
79
|
+
"checkpoint_id": self.checkpoint_id,
|
|
80
|
+
"checkpoint_name": self.checkpoint_name,
|
|
81
|
+
"current_entry_index": self.current_entry_index,
|
|
82
|
+
"target_entry_index": self.target_entry_index,
|
|
83
|
+
"entries_to_rollback": self.entries_to_rollback,
|
|
84
|
+
}
|
|
85
|
+
if self.diff:
|
|
86
|
+
d["diff"] = self.diff.to_dict()
|
|
87
|
+
return d
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CheckpointManager:
|
|
91
|
+
"""Manage checkpoints for an XY chain."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
chain: XYChain,
|
|
96
|
+
project_dir: str | Path | None = None,
|
|
97
|
+
storage_dir: str | Path = ".pruv/checkpoints",
|
|
98
|
+
compress: bool = True,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.chain = chain
|
|
101
|
+
self.project_dir = Path(project_dir) if project_dir else None
|
|
102
|
+
self.storage_dir = Path(storage_dir)
|
|
103
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
self.compress = compress and _HAS_ZSTD
|
|
105
|
+
self.checkpoints: list[Checkpoint] = []
|
|
106
|
+
|
|
107
|
+
# Register auto-checkpoint callback
|
|
108
|
+
self.chain._checkpoint_callback = lambda name: self.create(name)
|
|
109
|
+
|
|
110
|
+
def create(self, name: str, include_files: bool = False) -> Checkpoint:
|
|
111
|
+
"""Create a checkpoint at the current chain state."""
|
|
112
|
+
# Snapshot chain
|
|
113
|
+
chain_data = self.chain.to_dict()
|
|
114
|
+
|
|
115
|
+
# Snapshot graph
|
|
116
|
+
graph_data = None
|
|
117
|
+
file_snapshots = None
|
|
118
|
+
if self.project_dir and self.project_dir.exists():
|
|
119
|
+
graph = scan_dir(str(self.project_dir), include_contents=include_files)
|
|
120
|
+
graph_data = graph.to_dict()
|
|
121
|
+
if include_files:
|
|
122
|
+
file_snapshots = graph.file_contents
|
|
123
|
+
|
|
124
|
+
checkpoint = Checkpoint(
|
|
125
|
+
id=uuid.uuid4().hex[:12],
|
|
126
|
+
name=name,
|
|
127
|
+
chain_id=self.chain.id,
|
|
128
|
+
entry_index=self.chain.length - 1 if self.chain.length > 0 else -1,
|
|
129
|
+
created_at=time.time(),
|
|
130
|
+
chain_snapshot=chain_data,
|
|
131
|
+
graph_snapshot=graph_data,
|
|
132
|
+
file_snapshots=file_snapshots,
|
|
133
|
+
compressed=self.compress,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self.checkpoints.append(checkpoint)
|
|
137
|
+
self._save_checkpoint(checkpoint)
|
|
138
|
+
return checkpoint
|
|
139
|
+
|
|
140
|
+
def preview_restore(self, checkpoint_id: str) -> RestorePreview:
|
|
141
|
+
"""Preview what will change if restoring to a checkpoint."""
|
|
142
|
+
checkpoint = self._find_checkpoint(checkpoint_id)
|
|
143
|
+
current_index = self.chain.length - 1 if self.chain.length > 0 else -1
|
|
144
|
+
|
|
145
|
+
diff = None
|
|
146
|
+
if self.project_dir and checkpoint.graph_snapshot:
|
|
147
|
+
current_graph = scan_dir(str(self.project_dir))
|
|
148
|
+
old_graph = Graph.from_dict(checkpoint.graph_snapshot)
|
|
149
|
+
diff = old_graph.diff(current_graph)
|
|
150
|
+
|
|
151
|
+
return RestorePreview(
|
|
152
|
+
checkpoint_id=checkpoint.id,
|
|
153
|
+
checkpoint_name=checkpoint.name,
|
|
154
|
+
current_entry_index=current_index,
|
|
155
|
+
target_entry_index=checkpoint.entry_index,
|
|
156
|
+
entries_to_rollback=max(0, current_index - checkpoint.entry_index),
|
|
157
|
+
diff=diff,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def restore(self, checkpoint_id: str) -> XYChain:
|
|
161
|
+
"""Restore chain to a checkpoint state."""
|
|
162
|
+
checkpoint = self._find_checkpoint(checkpoint_id)
|
|
163
|
+
restored = XYChain.from_dict(checkpoint.chain_snapshot)
|
|
164
|
+
self.chain.entries = restored.entries
|
|
165
|
+
return self.chain
|
|
166
|
+
|
|
167
|
+
def quick_undo(self) -> XYChain | None:
|
|
168
|
+
"""Restore to the most recent checkpoint."""
|
|
169
|
+
if not self.checkpoints:
|
|
170
|
+
return None
|
|
171
|
+
latest = self.checkpoints[-1]
|
|
172
|
+
return self.restore(latest.id)
|
|
173
|
+
|
|
174
|
+
def list_checkpoints(self) -> list[dict[str, Any]]:
|
|
175
|
+
"""List all checkpoints."""
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
"id": cp.id,
|
|
179
|
+
"name": cp.name,
|
|
180
|
+
"entry_index": cp.entry_index,
|
|
181
|
+
"created_at": cp.created_at,
|
|
182
|
+
}
|
|
183
|
+
for cp in self.checkpoints
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def _find_checkpoint(self, checkpoint_id: str) -> Checkpoint:
|
|
187
|
+
for cp in self.checkpoints:
|
|
188
|
+
if cp.id == checkpoint_id:
|
|
189
|
+
return cp
|
|
190
|
+
raise ValueError(f"Checkpoint not found: {checkpoint_id}")
|
|
191
|
+
|
|
192
|
+
def _save_checkpoint(self, checkpoint: Checkpoint) -> Path:
|
|
193
|
+
"""Save checkpoint to disk."""
|
|
194
|
+
data = json.dumps(checkpoint.to_dict(), indent=2).encode("utf-8")
|
|
195
|
+
path = self.storage_dir / f"{checkpoint.id}.json"
|
|
196
|
+
|
|
197
|
+
if self.compress and _HAS_ZSTD:
|
|
198
|
+
cctx = zstandard.ZstdCompressor(level=3)
|
|
199
|
+
data = cctx.compress(data)
|
|
200
|
+
path = self.storage_dir / f"{checkpoint.id}.json.zst"
|
|
201
|
+
|
|
202
|
+
with open(path, "wb") as f:
|
|
203
|
+
f.write(data)
|
|
204
|
+
|
|
205
|
+
return path
|
|
206
|
+
|
|
207
|
+
def _load_checkpoint(self, checkpoint_id: str) -> Checkpoint:
|
|
208
|
+
"""Load checkpoint from disk."""
|
|
209
|
+
# Try compressed first
|
|
210
|
+
zst_path = self.storage_dir / f"{checkpoint_id}.json.zst"
|
|
211
|
+
json_path = self.storage_dir / f"{checkpoint_id}.json"
|
|
212
|
+
|
|
213
|
+
if zst_path.exists() and _HAS_ZSTD:
|
|
214
|
+
with open(zst_path, "rb") as f:
|
|
215
|
+
dctx = zstandard.ZstdDecompressor()
|
|
216
|
+
data = dctx.decompress(f.read())
|
|
217
|
+
elif json_path.exists():
|
|
218
|
+
with open(json_path, "rb") as f:
|
|
219
|
+
data = f.read()
|
|
220
|
+
else:
|
|
221
|
+
raise FileNotFoundError(f"Checkpoint not found on disk: {checkpoint_id}")
|
|
222
|
+
|
|
223
|
+
return Checkpoint.from_dict(json.loads(data))
|