AbstractRuntime 0.0.0__py3-none-any.whl → 0.2.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.
Files changed (34) hide show
  1. abstractruntime/__init__.py +104 -2
  2. abstractruntime/core/__init__.py +26 -0
  3. abstractruntime/core/config.py +101 -0
  4. abstractruntime/core/models.py +282 -0
  5. abstractruntime/core/policy.py +166 -0
  6. abstractruntime/core/runtime.py +736 -0
  7. abstractruntime/core/spec.py +53 -0
  8. abstractruntime/core/vars.py +94 -0
  9. abstractruntime/identity/__init__.py +7 -0
  10. abstractruntime/identity/fingerprint.py +57 -0
  11. abstractruntime/integrations/__init__.py +11 -0
  12. abstractruntime/integrations/abstractcore/__init__.py +47 -0
  13. abstractruntime/integrations/abstractcore/effect_handlers.py +119 -0
  14. abstractruntime/integrations/abstractcore/factory.py +187 -0
  15. abstractruntime/integrations/abstractcore/llm_client.py +397 -0
  16. abstractruntime/integrations/abstractcore/logging.py +27 -0
  17. abstractruntime/integrations/abstractcore/tool_executor.py +168 -0
  18. abstractruntime/scheduler/__init__.py +13 -0
  19. abstractruntime/scheduler/convenience.py +324 -0
  20. abstractruntime/scheduler/registry.py +101 -0
  21. abstractruntime/scheduler/scheduler.py +431 -0
  22. abstractruntime/storage/__init__.py +25 -0
  23. abstractruntime/storage/artifacts.py +519 -0
  24. abstractruntime/storage/base.py +107 -0
  25. abstractruntime/storage/in_memory.py +119 -0
  26. abstractruntime/storage/json_files.py +208 -0
  27. abstractruntime/storage/ledger_chain.py +153 -0
  28. abstractruntime/storage/snapshots.py +217 -0
  29. abstractruntime-0.2.0.dist-info/METADATA +163 -0
  30. abstractruntime-0.2.0.dist-info/RECORD +32 -0
  31. {abstractruntime-0.0.0.dist-info → abstractruntime-0.2.0.dist-info}/licenses/LICENSE +3 -1
  32. abstractruntime-0.0.0.dist-info/METADATA +0 -89
  33. abstractruntime-0.0.0.dist-info/RECORD +0 -5
  34. {abstractruntime-0.0.0.dist-info → abstractruntime-0.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,119 @@
1
+ """abstractruntime.storage.in_memory
2
+
3
+ In-memory durability backends (testing/dev).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import asdict
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .base import LedgerStore, RunStore
12
+ from ..core.models import RunState, RunStatus, StepRecord, WaitReason
13
+
14
+
15
+ class InMemoryRunStore(RunStore):
16
+ """In-memory run store with query support.
17
+
18
+ Implements both RunStore (ABC) and QueryableRunStore (Protocol).
19
+ """
20
+
21
+ def __init__(self):
22
+ self._runs: Dict[str, RunState] = {}
23
+
24
+ def save(self, run: RunState) -> None:
25
+ # store a shallow copy to avoid accidental mutation surprises
26
+ self._runs[run.run_id] = run
27
+
28
+ def load(self, run_id: str) -> Optional[RunState]:
29
+ return self._runs.get(run_id)
30
+
31
+ # --- QueryableRunStore methods ---
32
+
33
+ def list_runs(
34
+ self,
35
+ *,
36
+ status: Optional[RunStatus] = None,
37
+ wait_reason: Optional[WaitReason] = None,
38
+ workflow_id: Optional[str] = None,
39
+ limit: int = 100,
40
+ ) -> List[RunState]:
41
+ """List runs matching the given filters."""
42
+ results: List[RunState] = []
43
+
44
+ for run in self._runs.values():
45
+ # Apply filters
46
+ if status is not None and run.status != status:
47
+ continue
48
+ if workflow_id is not None and run.workflow_id != workflow_id:
49
+ continue
50
+ if wait_reason is not None:
51
+ if run.waiting is None or run.waiting.reason != wait_reason:
52
+ continue
53
+
54
+ results.append(run)
55
+
56
+ # Sort by updated_at descending (most recent first)
57
+ results.sort(key=lambda r: r.updated_at or "", reverse=True)
58
+
59
+ return results[:limit]
60
+
61
+ def list_due_wait_until(
62
+ self,
63
+ *,
64
+ now_iso: str,
65
+ limit: int = 100,
66
+ ) -> List[RunState]:
67
+ """List runs waiting for a time threshold that has passed."""
68
+ results: List[RunState] = []
69
+
70
+ for run in self._runs.values():
71
+ # Must be WAITING with reason UNTIL
72
+ if run.status != RunStatus.WAITING:
73
+ continue
74
+ if run.waiting is None:
75
+ continue
76
+ if run.waiting.reason != WaitReason.UNTIL:
77
+ continue
78
+ if run.waiting.until is None:
79
+ continue
80
+
81
+ # Check if the wait time has passed (ISO string comparison works for UTC)
82
+ if run.waiting.until <= now_iso:
83
+ results.append(run)
84
+
85
+ # Sort by waiting.until ascending (earliest due first)
86
+ results.sort(key=lambda r: r.waiting.until if r.waiting else "")
87
+
88
+ return results[:limit]
89
+
90
+ def list_children(
91
+ self,
92
+ *,
93
+ parent_run_id: str,
94
+ status: Optional[RunStatus] = None,
95
+ ) -> List[RunState]:
96
+ """List child runs of a parent."""
97
+ results: List[RunState] = []
98
+
99
+ for run in self._runs.values():
100
+ if run.parent_run_id != parent_run_id:
101
+ continue
102
+ if status is not None and run.status != status:
103
+ continue
104
+ results.append(run)
105
+
106
+ return results
107
+
108
+
109
+ class InMemoryLedgerStore(LedgerStore):
110
+ def __init__(self):
111
+ self._records: Dict[str, List[Dict[str, Any]]] = {}
112
+
113
+ def append(self, record: StepRecord) -> None:
114
+ self._records.setdefault(record.run_id, []).append(asdict(record))
115
+
116
+ def list(self, run_id: str) -> List[Dict[str, Any]]:
117
+ return list(self._records.get(run_id, []))
118
+
119
+
@@ -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
+ session_id=data.get("session_id"),
92
+ parent_run_id=data.get("parent_run_id"),
93
+ )
94
+
95
+ def _iter_all_runs(self) -> List[RunState]:
96
+ """Iterate over all stored runs."""
97
+ runs: List[RunState] = []
98
+ for p in self._base.glob("run_*.json"):
99
+ run = self._load_from_path(p)
100
+ if run is not None:
101
+ runs.append(run)
102
+ return runs
103
+
104
+ # --- QueryableRunStore methods ---
105
+
106
+ def list_runs(
107
+ self,
108
+ *,
109
+ status: Optional[RunStatus] = None,
110
+ wait_reason: Optional[WaitReason] = None,
111
+ workflow_id: Optional[str] = None,
112
+ limit: int = 100,
113
+ ) -> List[RunState]:
114
+ """List runs matching the given filters."""
115
+ results: List[RunState] = []
116
+
117
+ for run in self._iter_all_runs():
118
+ # Apply filters
119
+ if status is not None and run.status != status:
120
+ continue
121
+ if workflow_id is not None and run.workflow_id != workflow_id:
122
+ continue
123
+ if wait_reason is not None:
124
+ if run.waiting is None or run.waiting.reason != wait_reason:
125
+ continue
126
+
127
+ results.append(run)
128
+
129
+ # Sort by updated_at descending (most recent first)
130
+ results.sort(key=lambda r: r.updated_at or "", reverse=True)
131
+
132
+ return results[:limit]
133
+
134
+ def list_due_wait_until(
135
+ self,
136
+ *,
137
+ now_iso: str,
138
+ limit: int = 100,
139
+ ) -> List[RunState]:
140
+ """List runs waiting for a time threshold that has passed."""
141
+ results: List[RunState] = []
142
+
143
+ for run in self._iter_all_runs():
144
+ # Must be WAITING with reason UNTIL
145
+ if run.status != RunStatus.WAITING:
146
+ continue
147
+ if run.waiting is None:
148
+ continue
149
+ if run.waiting.reason != WaitReason.UNTIL:
150
+ continue
151
+ if run.waiting.until is None:
152
+ continue
153
+
154
+ # Check if the wait time has passed (ISO string comparison works for UTC)
155
+ if run.waiting.until <= now_iso:
156
+ results.append(run)
157
+
158
+ # Sort by waiting.until ascending (earliest due first)
159
+ results.sort(key=lambda r: r.waiting.until if r.waiting else "")
160
+
161
+ return results[:limit]
162
+
163
+ def list_children(
164
+ self,
165
+ *,
166
+ parent_run_id: str,
167
+ status: Optional[RunStatus] = None,
168
+ ) -> List[RunState]:
169
+ """List child runs of a parent."""
170
+ results: List[RunState] = []
171
+
172
+ for run in self._iter_all_runs():
173
+ if run.parent_run_id != parent_run_id:
174
+ continue
175
+ if status is not None and run.status != status:
176
+ continue
177
+ results.append(run)
178
+
179
+ return results
180
+
181
+
182
+ class JsonlLedgerStore(LedgerStore):
183
+ def __init__(self, base_dir: str | Path):
184
+ self._base = Path(base_dir)
185
+ self._base.mkdir(parents=True, exist_ok=True)
186
+
187
+ def _path(self, run_id: str) -> Path:
188
+ return self._base / f"ledger_{run_id}.jsonl"
189
+
190
+ def append(self, record: StepRecord) -> None:
191
+ p = self._path(record.run_id)
192
+ with p.open("a", encoding="utf-8") as f:
193
+ f.write(json.dumps(asdict(record), ensure_ascii=False))
194
+ f.write("\n")
195
+
196
+ def list(self, run_id: str) -> List[Dict[str, Any]]:
197
+ p = self._path(run_id)
198
+ if not p.exists():
199
+ return []
200
+ out: List[Dict[str, Any]] = []
201
+ with p.open("r", encoding="utf-8") as f:
202
+ for line in f:
203
+ line = line.strip()
204
+ if not line:
205
+ continue
206
+ out.append(json.loads(line))
207
+ return out
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
+