AbstractRuntime 0.0.0__py3-none-any.whl → 0.0.1__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.
- abstractruntime/__init__.py +104 -2
- abstractruntime/core/__init__.py +19 -0
- abstractruntime/core/models.py +239 -0
- abstractruntime/core/policy.py +166 -0
- abstractruntime/core/runtime.py +581 -0
- abstractruntime/core/spec.py +53 -0
- abstractruntime/identity/__init__.py +7 -0
- abstractruntime/identity/fingerprint.py +57 -0
- abstractruntime/integrations/__init__.py +11 -0
- abstractruntime/integrations/abstractcore/__init__.py +43 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +89 -0
- abstractruntime/integrations/abstractcore/factory.py +150 -0
- abstractruntime/integrations/abstractcore/llm_client.py +296 -0
- abstractruntime/integrations/abstractcore/logging.py +27 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +89 -0
- abstractruntime/scheduler/__init__.py +13 -0
- abstractruntime/scheduler/convenience.py +324 -0
- abstractruntime/scheduler/registry.py +101 -0
- abstractruntime/scheduler/scheduler.py +431 -0
- abstractruntime/storage/__init__.py +25 -0
- abstractruntime/storage/artifacts.py +488 -0
- abstractruntime/storage/base.py +107 -0
- abstractruntime/storage/in_memory.py +119 -0
- abstractruntime/storage/json_files.py +208 -0
- abstractruntime/storage/ledger_chain.py +153 -0
- abstractruntime/storage/snapshots.py +217 -0
- abstractruntime-0.0.1.dist-info/METADATA +163 -0
- abstractruntime-0.0.1.dist-info/RECORD +30 -0
- {abstractruntime-0.0.0.dist-info → abstractruntime-0.0.1.dist-info}/licenses/LICENSE +3 -1
- abstractruntime-0.0.0.dist-info/METADATA +0 -89
- abstractruntime-0.0.0.dist-info/RECORD +0 -5
- {abstractruntime-0.0.0.dist-info → abstractruntime-0.0.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""abstractruntime.storage.json_files
|
|
2
|
+
|
|
3
|
+
Simple file-based persistence:
|
|
4
|
+
- RunState checkpoints as JSON (one file per run)
|
|
5
|
+
- Ledger as JSONL (append-only)
|
|
6
|
+
|
|
7
|
+
This is meant as a straightforward MVP backend.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .base import LedgerStore, RunStore
|
|
18
|
+
from ..core.models import RunState, StepRecord, RunStatus, WaitState, WaitReason
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JsonFileRunStore(RunStore):
|
|
22
|
+
"""File-based run store with query support.
|
|
23
|
+
|
|
24
|
+
Implements both RunStore (ABC) and QueryableRunStore (Protocol).
|
|
25
|
+
|
|
26
|
+
Query operations scan all run_*.json files, which is acceptable for MVP
|
|
27
|
+
but may need indexing for large deployments.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_dir: str | Path):
|
|
31
|
+
self._base = Path(base_dir)
|
|
32
|
+
self._base.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
def _path(self, run_id: str) -> Path:
|
|
35
|
+
return self._base / f"run_{run_id}.json"
|
|
36
|
+
|
|
37
|
+
def save(self, run: RunState) -> None:
|
|
38
|
+
p = self._path(run.run_id)
|
|
39
|
+
with p.open("w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(asdict(run), f, ensure_ascii=False, indent=2)
|
|
41
|
+
|
|
42
|
+
def load(self, run_id: str) -> Optional[RunState]:
|
|
43
|
+
p = self._path(run_id)
|
|
44
|
+
if not p.exists():
|
|
45
|
+
return None
|
|
46
|
+
return self._load_from_path(p)
|
|
47
|
+
|
|
48
|
+
def _load_from_path(self, p: Path) -> Optional[RunState]:
|
|
49
|
+
"""Load a RunState from a file path."""
|
|
50
|
+
try:
|
|
51
|
+
with p.open("r", encoding="utf-8") as f:
|
|
52
|
+
data = json.load(f)
|
|
53
|
+
except (json.JSONDecodeError, IOError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# Reconstruct enums and nested dataclasses
|
|
57
|
+
raw_status = data.get("status")
|
|
58
|
+
status = raw_status if isinstance(raw_status, RunStatus) else RunStatus(str(raw_status))
|
|
59
|
+
|
|
60
|
+
waiting: Optional[WaitState] = None
|
|
61
|
+
raw_waiting = data.get("waiting")
|
|
62
|
+
if isinstance(raw_waiting, dict):
|
|
63
|
+
raw_reason = raw_waiting.get("reason")
|
|
64
|
+
if raw_reason is None:
|
|
65
|
+
raise ValueError("Persisted waiting state missing 'reason'")
|
|
66
|
+
reason = raw_reason if isinstance(raw_reason, WaitReason) else WaitReason(str(raw_reason))
|
|
67
|
+
waiting = WaitState(
|
|
68
|
+
reason=reason,
|
|
69
|
+
wait_key=raw_waiting.get("wait_key"),
|
|
70
|
+
until=raw_waiting.get("until"),
|
|
71
|
+
resume_to_node=raw_waiting.get("resume_to_node"),
|
|
72
|
+
result_key=raw_waiting.get("result_key"),
|
|
73
|
+
prompt=raw_waiting.get("prompt"),
|
|
74
|
+
choices=raw_waiting.get("choices"),
|
|
75
|
+
allow_free_text=bool(raw_waiting.get("allow_free_text", True)),
|
|
76
|
+
details=raw_waiting.get("details"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return RunState(
|
|
80
|
+
run_id=data["run_id"],
|
|
81
|
+
workflow_id=data["workflow_id"],
|
|
82
|
+
status=status,
|
|
83
|
+
current_node=data["current_node"],
|
|
84
|
+
vars=data.get("vars") or {},
|
|
85
|
+
waiting=waiting,
|
|
86
|
+
output=data.get("output"),
|
|
87
|
+
error=data.get("error"),
|
|
88
|
+
created_at=data.get("created_at"),
|
|
89
|
+
updated_at=data.get("updated_at"),
|
|
90
|
+
actor_id=data.get("actor_id"),
|
|
91
|
+
parent_run_id=data.get("parent_run_id"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _iter_all_runs(self) -> List[RunState]:
|
|
95
|
+
"""Iterate over all stored runs."""
|
|
96
|
+
runs: List[RunState] = []
|
|
97
|
+
for p in self._base.glob("run_*.json"):
|
|
98
|
+
run = self._load_from_path(p)
|
|
99
|
+
if run is not None:
|
|
100
|
+
runs.append(run)
|
|
101
|
+
return runs
|
|
102
|
+
|
|
103
|
+
# --- QueryableRunStore methods ---
|
|
104
|
+
|
|
105
|
+
def list_runs(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
status: Optional[RunStatus] = None,
|
|
109
|
+
wait_reason: Optional[WaitReason] = None,
|
|
110
|
+
workflow_id: Optional[str] = None,
|
|
111
|
+
limit: int = 100,
|
|
112
|
+
) -> List[RunState]:
|
|
113
|
+
"""List runs matching the given filters."""
|
|
114
|
+
results: List[RunState] = []
|
|
115
|
+
|
|
116
|
+
for run in self._iter_all_runs():
|
|
117
|
+
# Apply filters
|
|
118
|
+
if status is not None and run.status != status:
|
|
119
|
+
continue
|
|
120
|
+
if workflow_id is not None and run.workflow_id != workflow_id:
|
|
121
|
+
continue
|
|
122
|
+
if wait_reason is not None:
|
|
123
|
+
if run.waiting is None or run.waiting.reason != wait_reason:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
results.append(run)
|
|
127
|
+
|
|
128
|
+
# Sort by updated_at descending (most recent first)
|
|
129
|
+
results.sort(key=lambda r: r.updated_at or "", reverse=True)
|
|
130
|
+
|
|
131
|
+
return results[:limit]
|
|
132
|
+
|
|
133
|
+
def list_due_wait_until(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
now_iso: str,
|
|
137
|
+
limit: int = 100,
|
|
138
|
+
) -> List[RunState]:
|
|
139
|
+
"""List runs waiting for a time threshold that has passed."""
|
|
140
|
+
results: List[RunState] = []
|
|
141
|
+
|
|
142
|
+
for run in self._iter_all_runs():
|
|
143
|
+
# Must be WAITING with reason UNTIL
|
|
144
|
+
if run.status != RunStatus.WAITING:
|
|
145
|
+
continue
|
|
146
|
+
if run.waiting is None:
|
|
147
|
+
continue
|
|
148
|
+
if run.waiting.reason != WaitReason.UNTIL:
|
|
149
|
+
continue
|
|
150
|
+
if run.waiting.until is None:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
# Check if the wait time has passed (ISO string comparison works for UTC)
|
|
154
|
+
if run.waiting.until <= now_iso:
|
|
155
|
+
results.append(run)
|
|
156
|
+
|
|
157
|
+
# Sort by waiting.until ascending (earliest due first)
|
|
158
|
+
results.sort(key=lambda r: r.waiting.until if r.waiting else "")
|
|
159
|
+
|
|
160
|
+
return results[:limit]
|
|
161
|
+
|
|
162
|
+
def list_children(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
parent_run_id: str,
|
|
166
|
+
status: Optional[RunStatus] = None,
|
|
167
|
+
) -> List[RunState]:
|
|
168
|
+
"""List child runs of a parent."""
|
|
169
|
+
results: List[RunState] = []
|
|
170
|
+
|
|
171
|
+
for run in self._iter_all_runs():
|
|
172
|
+
if run.parent_run_id != parent_run_id:
|
|
173
|
+
continue
|
|
174
|
+
if status is not None and run.status != status:
|
|
175
|
+
continue
|
|
176
|
+
results.append(run)
|
|
177
|
+
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class JsonlLedgerStore(LedgerStore):
|
|
182
|
+
def __init__(self, base_dir: str | Path):
|
|
183
|
+
self._base = Path(base_dir)
|
|
184
|
+
self._base.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
|
|
186
|
+
def _path(self, run_id: str) -> Path:
|
|
187
|
+
return self._base / f"ledger_{run_id}.jsonl"
|
|
188
|
+
|
|
189
|
+
def append(self, record: StepRecord) -> None:
|
|
190
|
+
p = self._path(record.run_id)
|
|
191
|
+
with p.open("a", encoding="utf-8") as f:
|
|
192
|
+
f.write(json.dumps(asdict(record), ensure_ascii=False))
|
|
193
|
+
f.write("\n")
|
|
194
|
+
|
|
195
|
+
def list(self, run_id: str) -> List[Dict[str, Any]]:
|
|
196
|
+
p = self._path(run_id)
|
|
197
|
+
if not p.exists():
|
|
198
|
+
return []
|
|
199
|
+
out: List[Dict[str, Any]] = []
|
|
200
|
+
with p.open("r", encoding="utf-8") as f:
|
|
201
|
+
for line in f:
|
|
202
|
+
line = line.strip()
|
|
203
|
+
if not line:
|
|
204
|
+
continue
|
|
205
|
+
out.append(json.loads(line))
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""abstractruntime.storage.ledger_chain
|
|
2
|
+
|
|
3
|
+
Tamper-evident provenance for the execution ledger.
|
|
4
|
+
|
|
5
|
+
This module provides:
|
|
6
|
+
- A `HashChainedLedgerStore` decorator that wraps any `LedgerStore` and injects
|
|
7
|
+
`prev_hash` + `record_hash` into each appended `StepRecord`.
|
|
8
|
+
- A `verify_ledger_chain()` utility to validate the chain.
|
|
9
|
+
|
|
10
|
+
Important scope boundary:
|
|
11
|
+
- This is **tamper-evident**, not tamper-proof.
|
|
12
|
+
- Cryptographic signatures (non-forgeability) are intentionally out of scope for v0.1.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import asdict
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from .base import LedgerStore
|
|
23
|
+
from ..core.models import StepRecord
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canonical_json(data: Dict[str, Any]) -> str:
|
|
27
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha256_hex(text: str) -> str:
|
|
31
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compute_record_hash(*, record: Dict[str, Any], prev_hash: Optional[str]) -> str:
|
|
35
|
+
"""Compute record hash from a JSON dict.
|
|
36
|
+
|
|
37
|
+
Rules:
|
|
38
|
+
- `prev_hash` is included in the hashed payload.
|
|
39
|
+
- `record_hash` and `signature` fields are excluded (to avoid recursion).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
clean = dict(record)
|
|
43
|
+
clean.pop("record_hash", None)
|
|
44
|
+
clean.pop("signature", None)
|
|
45
|
+
|
|
46
|
+
clean["prev_hash"] = prev_hash
|
|
47
|
+
return _sha256_hex(_canonical_json(clean))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HashChainedLedgerStore(LedgerStore):
|
|
51
|
+
"""LedgerStore decorator adding a SHA-256 hash chain."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, inner: LedgerStore):
|
|
54
|
+
self._inner = inner
|
|
55
|
+
self._head_by_run: Dict[str, Optional[str]] = {}
|
|
56
|
+
|
|
57
|
+
def _get_head(self, run_id: str) -> Optional[str]:
|
|
58
|
+
if run_id in self._head_by_run:
|
|
59
|
+
return self._head_by_run[run_id]
|
|
60
|
+
|
|
61
|
+
# Best-effort bootstrap for process restarts.
|
|
62
|
+
records = self._inner.list(run_id)
|
|
63
|
+
if not records:
|
|
64
|
+
self._head_by_run[run_id] = None
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
last = records[-1]
|
|
68
|
+
head = last.get("record_hash")
|
|
69
|
+
self._head_by_run[run_id] = head
|
|
70
|
+
return head
|
|
71
|
+
|
|
72
|
+
def append(self, record: StepRecord) -> None:
|
|
73
|
+
prev = self._get_head(record.run_id)
|
|
74
|
+
|
|
75
|
+
# Compute hash from record dict + prev hash.
|
|
76
|
+
record.prev_hash = prev
|
|
77
|
+
record_dict = asdict(record)
|
|
78
|
+
record_hash = compute_record_hash(record=record_dict, prev_hash=prev)
|
|
79
|
+
record.record_hash = record_hash
|
|
80
|
+
|
|
81
|
+
self._inner.append(record)
|
|
82
|
+
self._head_by_run[record.run_id] = record_hash
|
|
83
|
+
|
|
84
|
+
def list(self, run_id: str) -> List[Dict[str, Any]]:
|
|
85
|
+
return self._inner.list(run_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify_ledger_chain(records: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
89
|
+
"""Verify a list of stored ledger records.
|
|
90
|
+
|
|
91
|
+
Returns a structured report with the first failing index and error details.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
report: Dict[str, Any] = {
|
|
95
|
+
"ok": True,
|
|
96
|
+
"count": len(records),
|
|
97
|
+
"errors": [],
|
|
98
|
+
"first_bad_index": None,
|
|
99
|
+
"head_hash": records[-1].get("record_hash") if records else None,
|
|
100
|
+
"computed_head_hash": None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
prev: Optional[str] = None
|
|
104
|
+
computed_head: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
for i, r in enumerate(records):
|
|
107
|
+
expected_prev = prev
|
|
108
|
+
actual_prev = r.get("prev_hash")
|
|
109
|
+
|
|
110
|
+
if actual_prev != expected_prev:
|
|
111
|
+
report["ok"] = False
|
|
112
|
+
report["first_bad_index"] = report["first_bad_index"] or i
|
|
113
|
+
report["errors"].append(
|
|
114
|
+
{
|
|
115
|
+
"index": i,
|
|
116
|
+
"type": "prev_hash_mismatch",
|
|
117
|
+
"expected_prev_hash": expected_prev,
|
|
118
|
+
"actual_prev_hash": actual_prev,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
stored_hash = r.get("record_hash")
|
|
123
|
+
if not stored_hash:
|
|
124
|
+
report["ok"] = False
|
|
125
|
+
report["first_bad_index"] = report["first_bad_index"] or i
|
|
126
|
+
report["errors"].append(
|
|
127
|
+
{
|
|
128
|
+
"index": i,
|
|
129
|
+
"type": "missing_record_hash",
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
# Cannot continue computing chain reliably
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
computed_hash = compute_record_hash(record=r, prev_hash=actual_prev)
|
|
136
|
+
if computed_hash != stored_hash:
|
|
137
|
+
report["ok"] = False
|
|
138
|
+
report["first_bad_index"] = report["first_bad_index"] or i
|
|
139
|
+
report["errors"].append(
|
|
140
|
+
{
|
|
141
|
+
"index": i,
|
|
142
|
+
"type": "record_hash_mismatch",
|
|
143
|
+
"stored": stored_hash,
|
|
144
|
+
"computed": computed_hash,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
prev = stored_hash
|
|
149
|
+
computed_head = stored_hash
|
|
150
|
+
|
|
151
|
+
report["computed_head_hash"] = computed_head
|
|
152
|
+
return report
|
|
153
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""abstractruntime.storage.snapshots
|
|
2
|
+
|
|
3
|
+
Named snapshots/bookmarks for durable run state.
|
|
4
|
+
|
|
5
|
+
A snapshot is a *user/operator-facing checkpoint* with:
|
|
6
|
+
- name/description/tags
|
|
7
|
+
- timestamps
|
|
8
|
+
- embedded (JSON) run state
|
|
9
|
+
|
|
10
|
+
This is intentionally simple for v0.1 (file-per-snapshot).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import uuid
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from ..core.models import RunState
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def utc_now_iso() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Snapshot:
|
|
32
|
+
snapshot_id: str
|
|
33
|
+
run_id: str
|
|
34
|
+
|
|
35
|
+
step_id: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
name: str = ""
|
|
38
|
+
description: Optional[str] = None
|
|
39
|
+
tags: List[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
created_at: str = field(default_factory=utc_now_iso)
|
|
42
|
+
updated_at: str = field(default_factory=utc_now_iso)
|
|
43
|
+
|
|
44
|
+
# JSON-only representation of the run state
|
|
45
|
+
run_state: Dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_run(
|
|
49
|
+
cls,
|
|
50
|
+
*,
|
|
51
|
+
run: RunState,
|
|
52
|
+
name: str,
|
|
53
|
+
description: Optional[str] = None,
|
|
54
|
+
tags: Optional[List[str]] = None,
|
|
55
|
+
step_id: Optional[str] = None,
|
|
56
|
+
) -> "Snapshot":
|
|
57
|
+
return cls(
|
|
58
|
+
snapshot_id=str(uuid.uuid4()),
|
|
59
|
+
run_id=run.run_id,
|
|
60
|
+
step_id=step_id,
|
|
61
|
+
name=name,
|
|
62
|
+
description=description,
|
|
63
|
+
tags=list(tags or []),
|
|
64
|
+
run_state=asdict(run),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SnapshotStore(ABC):
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def save(self, snapshot: Snapshot) -> None: ...
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def load(self, snapshot_id: str) -> Optional[Snapshot]: ...
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def delete(self, snapshot_id: str) -> bool: ...
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def list(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
run_id: Optional[str] = None,
|
|
83
|
+
tag: Optional[str] = None,
|
|
84
|
+
query: Optional[str] = None,
|
|
85
|
+
limit: int = 100,
|
|
86
|
+
) -> List[Snapshot]: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InMemorySnapshotStore(SnapshotStore):
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self._snaps: Dict[str, Snapshot] = {}
|
|
92
|
+
|
|
93
|
+
def save(self, snapshot: Snapshot) -> None:
|
|
94
|
+
snapshot.updated_at = utc_now_iso()
|
|
95
|
+
self._snaps[snapshot.snapshot_id] = snapshot
|
|
96
|
+
|
|
97
|
+
def load(self, snapshot_id: str) -> Optional[Snapshot]:
|
|
98
|
+
return self._snaps.get(snapshot_id)
|
|
99
|
+
|
|
100
|
+
def delete(self, snapshot_id: str) -> bool:
|
|
101
|
+
return self._snaps.pop(snapshot_id, None) is not None
|
|
102
|
+
|
|
103
|
+
def list(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
run_id: Optional[str] = None,
|
|
107
|
+
tag: Optional[str] = None,
|
|
108
|
+
query: Optional[str] = None,
|
|
109
|
+
limit: int = 100,
|
|
110
|
+
) -> List[Snapshot]:
|
|
111
|
+
snaps = list(self._snaps.values())
|
|
112
|
+
return _filter_snapshots(snaps, run_id=run_id, tag=tag, query=query, limit=limit)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class JsonSnapshotStore(SnapshotStore):
|
|
116
|
+
"""File-based snapshot store (one JSON file per snapshot)."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, base_dir: str | Path):
|
|
119
|
+
self._base = Path(base_dir)
|
|
120
|
+
self._base.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
|
|
122
|
+
def _path(self, snapshot_id: str) -> Path:
|
|
123
|
+
return self._base / f"snapshot_{snapshot_id}.json"
|
|
124
|
+
|
|
125
|
+
def save(self, snapshot: Snapshot) -> None:
|
|
126
|
+
snapshot.updated_at = utc_now_iso()
|
|
127
|
+
p = self._path(snapshot.snapshot_id)
|
|
128
|
+
with p.open("w", encoding="utf-8") as f:
|
|
129
|
+
json.dump(asdict(snapshot), f, ensure_ascii=False, indent=2)
|
|
130
|
+
|
|
131
|
+
def load(self, snapshot_id: str) -> Optional[Snapshot]:
|
|
132
|
+
p = self._path(snapshot_id)
|
|
133
|
+
if not p.exists():
|
|
134
|
+
return None
|
|
135
|
+
with p.open("r", encoding="utf-8") as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
return Snapshot(
|
|
138
|
+
snapshot_id=data["snapshot_id"],
|
|
139
|
+
run_id=data["run_id"],
|
|
140
|
+
step_id=data.get("step_id"),
|
|
141
|
+
name=data.get("name") or "",
|
|
142
|
+
description=data.get("description"),
|
|
143
|
+
tags=list(data.get("tags") or []),
|
|
144
|
+
created_at=data.get("created_at") or utc_now_iso(),
|
|
145
|
+
updated_at=data.get("updated_at") or utc_now_iso(),
|
|
146
|
+
run_state=dict(data.get("run_state") or {}),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def delete(self, snapshot_id: str) -> bool:
|
|
150
|
+
p = self._path(snapshot_id)
|
|
151
|
+
if not p.exists():
|
|
152
|
+
return False
|
|
153
|
+
p.unlink()
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def list(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
run_id: Optional[str] = None,
|
|
160
|
+
tag: Optional[str] = None,
|
|
161
|
+
query: Optional[str] = None,
|
|
162
|
+
limit: int = 100,
|
|
163
|
+
) -> List[Snapshot]:
|
|
164
|
+
snaps: List[Snapshot] = []
|
|
165
|
+
for p in sorted(self._base.glob("snapshot_*.json")):
|
|
166
|
+
try:
|
|
167
|
+
with p.open("r", encoding="utf-8") as f:
|
|
168
|
+
data = json.load(f)
|
|
169
|
+
snaps.append(
|
|
170
|
+
Snapshot(
|
|
171
|
+
snapshot_id=data["snapshot_id"],
|
|
172
|
+
run_id=data["run_id"],
|
|
173
|
+
step_id=data.get("step_id"),
|
|
174
|
+
name=data.get("name") or "",
|
|
175
|
+
description=data.get("description"),
|
|
176
|
+
tags=list(data.get("tags") or []),
|
|
177
|
+
created_at=data.get("created_at") or utc_now_iso(),
|
|
178
|
+
updated_at=data.get("updated_at") or utc_now_iso(),
|
|
179
|
+
run_state=dict(data.get("run_state") or {}),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
except Exception:
|
|
183
|
+
# Corrupt snapshot files are ignored for now (MVP); verification tools can be added later.
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
return _filter_snapshots(snaps, run_id=run_id, tag=tag, query=query, limit=limit)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _filter_snapshots(
|
|
190
|
+
snaps: List[Snapshot],
|
|
191
|
+
*,
|
|
192
|
+
run_id: Optional[str],
|
|
193
|
+
tag: Optional[str],
|
|
194
|
+
query: Optional[str],
|
|
195
|
+
limit: int,
|
|
196
|
+
) -> List[Snapshot]:
|
|
197
|
+
out = snaps
|
|
198
|
+
|
|
199
|
+
if run_id:
|
|
200
|
+
out = [s for s in out if s.run_id == run_id]
|
|
201
|
+
|
|
202
|
+
if tag:
|
|
203
|
+
tag_l = tag.lower()
|
|
204
|
+
out = [s for s in out if any(t.lower() == tag_l for t in (s.tags or []))]
|
|
205
|
+
|
|
206
|
+
if query:
|
|
207
|
+
q = query.lower()
|
|
208
|
+
def _matches(s: Snapshot) -> bool:
|
|
209
|
+
name = (s.name or "").lower()
|
|
210
|
+
desc = (s.description or "").lower()
|
|
211
|
+
return q in name or q in desc
|
|
212
|
+
out = [s for s in out if _matches(s)]
|
|
213
|
+
|
|
214
|
+
# Newest first (best-effort ISO ordering)
|
|
215
|
+
out.sort(key=lambda s: s.created_at or "", reverse=True)
|
|
216
|
+
return out[: max(0, int(limit))]
|
|
217
|
+
|