cli-agent-yzf 0.6.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,345 @@
1
+ """File checkpoints so the user can undo what the agent's file tools changed.
2
+
3
+ Each user prompt starts a checkpoint. The first time a file tool writes a path
4
+ during that prompt, the original content (or "did not exist") is recorded,
5
+ along with a hash of what the agent last wrote. ``undo`` restores the
6
+ originals of the most recent checkpoint, but skips any file that changed since
7
+ the agent wrote it, so it never discards the user's own later edits.
8
+
9
+ Only write_file/edit_file are tracked; changes made by shell commands are not,
10
+ but the number of shell commands is, so undo can say they ran.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import contextlib
17
+ import fcntl
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import secrets
22
+ import time
23
+ from collections.abc import Callable, Iterator
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ MAX_CHECKPOINTS = 20
29
+ LOCK_TIMEOUT = 10.0
30
+ """Seconds to wait for another cli-agent process to release the checkpoint lock."""
31
+
32
+
33
+ class CheckpointBusy(OSError):
34
+ """The checkpoint lock stayed held by another process past LOCK_TIMEOUT."""
35
+
36
+
37
+ FORMAT_VERSION = 2
38
+
39
+
40
+ def _digest(data: bytes) -> str:
41
+ return hashlib.sha256(data).hexdigest()
42
+
43
+
44
+ def shell_count(cp: dict[str, Any]) -> int:
45
+ """The checkpoint's shell-command count; 0 if missing or not a sane integer."""
46
+ v = cp.get("shell", 0)
47
+ return v if type(v) is int and 0 <= v < 1_000_000_000 else 0
48
+
49
+
50
+ def _valid(stack: object) -> bool:
51
+ if not isinstance(stack, list):
52
+ return False
53
+ for cp in stack:
54
+ if not (isinstance(cp, dict) and isinstance(cp.get("label"), str)):
55
+ return False
56
+ files = cp.get("files")
57
+ if not isinstance(files, dict):
58
+ return False
59
+ for entry in files.values():
60
+ if not (
61
+ isinstance(entry, dict)
62
+ and isinstance(entry.get("written"), str)
63
+ and (entry.get("original") is None or isinstance(entry.get("original"), str))
64
+ ):
65
+ return False
66
+ return True
67
+
68
+
69
+ @dataclass
70
+ class UndoReport:
71
+ restored: list[str] = field(default_factory=list)
72
+ deleted: list[str] = field(default_factory=list)
73
+ skipped: list[str] = field(default_factory=list)
74
+ """Files changed after the agent wrote them; left alone."""
75
+ label: str = ""
76
+ shell_commands: int = 0
77
+ """Shell commands run during the prompt; whatever they changed stays."""
78
+
79
+ @property
80
+ def files_changed(self) -> bool:
81
+ return bool(self.restored or self.deleted or self.skipped)
82
+
83
+
84
+ class Checkpoints:
85
+ """The file is the source of truth. Every change runs under an exclusive
86
+ lock as load, modify, save, so several cli-agent processes in one
87
+ workspace don't lose each other's checkpoints."""
88
+
89
+ def __init__(self, path: Path, root: Path):
90
+ self.path = path
91
+ self.root = root.resolve()
92
+ self._current: str | None = None
93
+ """Id of the checkpoint this process is recording into."""
94
+ self._shell = 0
95
+ """Shell commands in the current prompt; saved on the first and by `finish`."""
96
+ self._shell_saved = 0.0
97
+
98
+ def begin(self, label: str) -> None:
99
+ cid = secrets.token_hex(8)
100
+ # Until the new checkpoint is saved, record nothing into the previous one.
101
+ self._current = None
102
+ self._shell = 0
103
+
104
+ def add(stack: list[dict[str, Any]]) -> None:
105
+ # Prompts that changed nothing must not push real checkpoints out of the
106
+ # cap. Another live process's empty checkpoint may still get files, so
107
+ # only ours and those of exited processes are dropped.
108
+ stack[:] = [cp for cp in stack if cp["files"] or shell_count(cp) or _owner_alive(cp)]
109
+ stack.append(
110
+ {
111
+ "id": cid,
112
+ "owner": os.getpid(),
113
+ "label": " ".join(label.split())[:80],
114
+ "files": {},
115
+ }
116
+ )
117
+ _trim(stack)
118
+
119
+ self._update(add)
120
+ self._current = cid
121
+
122
+ def record(self, path: Path, before: bytes | None, after: str) -> None:
123
+ """Record a write. `before` is the file's exact bytes before this write
124
+ (None if it did not exist); `after` is the text the agent wrote."""
125
+ if self._current is None:
126
+ self.begin("(unlabelled)")
127
+ rel = str(path.relative_to(self.root))
128
+ written = _digest(_encode(after))
129
+
130
+ def add(stack: list[dict[str, Any]]) -> None:
131
+ cp = next((c for c in stack if c.get("id") == self._current), None)
132
+ if cp is None: # evicted by other processes meanwhile; start again
133
+ cp = {"id": self._current, "label": "(continued)", "files": {}}
134
+ stack.append(cp)
135
+ _trim(stack)
136
+ entry = cp["files"].get(rel)
137
+ if entry:
138
+ original = entry["original"]
139
+ else:
140
+ original = None if before is None else base64.b64encode(before).decode("ascii")
141
+ cp["files"][rel] = {"original": original, "written": written}
142
+
143
+ self._update(add)
144
+
145
+ def note_shell(self) -> None:
146
+ """Count a shell command against the current prompt. Only the first one
147
+ is written at once (rewriting the file per command is slow when it holds
148
+ large originals); `finish` writes the final count."""
149
+ if self._current is None:
150
+ return
151
+ self._shell += 1
152
+ # Also save now and then, so a killed process (SIGHUP) mostly keeps its count.
153
+ if self._shell == 1 or time.monotonic() - self._shell_saved >= 1.0:
154
+ self._save_shell()
155
+
156
+ def finish(self) -> None:
157
+ """End of the prompt's run: persist the shell-command count."""
158
+ if self._current is not None and self._shell > 1: # may already be saved; harmless
159
+ self._save_shell()
160
+
161
+ def _save_shell(self) -> None:
162
+ count = self._shell
163
+ self._shell_saved = time.monotonic()
164
+
165
+ def add(stack: list[dict[str, Any]]) -> None:
166
+ cp = next((c for c in stack if c.get("id") == self._current), None)
167
+ if cp is not None:
168
+ cp["shell"] = count
169
+
170
+ self._update(add)
171
+
172
+ def undo(self) -> UndoReport | None:
173
+ """Revert the most recent checkpoint that changed files or ran shell
174
+ commands; None if none."""
175
+ found: list[dict[str, Any]] = []
176
+
177
+ def take(stack: list[dict[str, Any]]) -> None:
178
+ while stack and not stack[-1]["files"] and not shell_count(stack[-1]):
179
+ stack.pop()
180
+ if stack:
181
+ found.append(stack.pop())
182
+
183
+ with self._locked():
184
+ stack = self._load()
185
+ take(stack)
186
+ if not found:
187
+ self._save(stack)
188
+ return None
189
+ cp = found[0]
190
+ report = UndoReport(label=cp["label"], shell_commands=shell_count(cp))
191
+ try:
192
+ self._revert(cp, report)
193
+ except OSError as err:
194
+ # Keep what was not reverted, so a retry can finish the job.
195
+ done = set(report.restored) | set(report.deleted)
196
+ cp["files"] = {k: v for k, v in cp["files"].items() if k not in done}
197
+ stack.append(cp)
198
+ self._save(stack)
199
+ reverted = ", ".join(sorted(done)) or "nothing"
200
+ raise OSError(
201
+ f"{err}; reverted before the failure: {reverted}. "
202
+ "Fix the cause and run undo again for the rest"
203
+ ) from err
204
+ self._save(stack)
205
+ return report
206
+
207
+ def _revert(self, cp: dict[str, Any], report: UndoReport) -> None:
208
+ for rel, entry in sorted(cp["files"].items()):
209
+ target = self.root / rel
210
+ # Never follow a symlink (the user may have swapped one in since) and
211
+ # never leave the workspace (a tampered checkpoint file).
212
+ if _has_symlink(self.root, target) or not _inside(self.root, target):
213
+ report.skipped.append(rel)
214
+ continue
215
+ current = _read(target)
216
+ if current is None or _digest(current) != entry["written"]:
217
+ report.skipped.append(rel)
218
+ continue
219
+ if entry["original"] is None:
220
+ target.unlink()
221
+ report.deleted.append(rel)
222
+ else:
223
+ _write(target, base64.b64decode(entry["original"]))
224
+ report.restored.append(rel)
225
+
226
+ def _update(self, change: Callable[[list[dict[str, Any]]], None]) -> None:
227
+ with self._locked():
228
+ stack = self._load()
229
+ change(stack)
230
+ self._save(stack)
231
+
232
+ @contextlib.contextmanager
233
+ def _locked(self) -> Iterator[None]:
234
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
235
+ lock = self.path.with_suffix(".lock")
236
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
237
+ try:
238
+ # Poll rather than block, so a stuck holder (e.g. a command the model
239
+ # started that inherited the lock) can't hang every file tool forever.
240
+ deadline = time.monotonic() + LOCK_TIMEOUT
241
+ while True:
242
+ try:
243
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
244
+ break
245
+ except BlockingIOError:
246
+ if time.monotonic() >= deadline:
247
+ raise CheckpointBusy(
248
+ f"undo checkpoints are locked by another process ({lock}); "
249
+ "close other cli-agent sessions in this workspace"
250
+ ) from None
251
+ time.sleep(0.05)
252
+ yield
253
+ finally:
254
+ os.close(fd) # closing releases the lock
255
+
256
+ def _load(self) -> list[dict[str, Any]]:
257
+ # A corrupt or older-format file is discarded rather than trusted.
258
+ try:
259
+ data = json.loads(self.path.read_text(encoding="utf-8"))
260
+ except (OSError, ValueError):
261
+ return []
262
+ if not isinstance(data, dict) or data.get("version") != FORMAT_VERSION:
263
+ return []
264
+ stack = data.get("checkpoints")
265
+ if not isinstance(stack, list):
266
+ return []
267
+ return stack if _valid(stack) else []
268
+
269
+ def _save(self, stack: list[dict[str, Any]]) -> None:
270
+ tmp = self.path.with_suffix(f".{os.getpid()}.tmp")
271
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
272
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
273
+ os.fchmod(fh.fileno(), 0o600)
274
+ json.dump({"version": FORMAT_VERSION, "checkpoints": stack}, fh)
275
+ os.replace(tmp, self.path)
276
+
277
+
278
+ def _trim(stack: list[dict[str, Any]]) -> None:
279
+ """Enforce MAX_CHECKPOINTS on checkpoints that hold something. Empty ones
280
+ (the prompt just begun, or another live process's) don't count, so they
281
+ can't push real file changes out of reach of undo. Prompts that only ran
282
+ shell commands hold no data and go first, oldest first."""
283
+
284
+ def held(cp: dict[str, Any]) -> bool:
285
+ return bool(cp["files"]) or shell_count(cp) > 0
286
+
287
+ while sum(held(cp) for cp in stack) > MAX_CHECKPOINTS:
288
+ shell_only = [i for i, cp in enumerate(stack) if not cp["files"] and held(cp)]
289
+ if shell_only:
290
+ del stack[shell_only[0]]
291
+ else:
292
+ del stack[next(i for i, cp in enumerate(stack) if held(cp))]
293
+
294
+
295
+ def _owner_alive(cp: dict[str, Any]) -> bool:
296
+ """True if the checkpoint belongs to another process that is still running."""
297
+ owner = cp.get("owner")
298
+ if not isinstance(owner, int) or owner == os.getpid():
299
+ return False
300
+ try:
301
+ os.kill(owner, 0)
302
+ except ProcessLookupError:
303
+ return False
304
+ except PermissionError:
305
+ return True
306
+ return True
307
+
308
+
309
+ def _inside(root: Path, target: Path) -> bool:
310
+ resolved = target.resolve()
311
+ return resolved != root and root in resolved.parents
312
+
313
+
314
+ def _has_symlink(root: Path, target: Path) -> bool:
315
+ try:
316
+ parts = target.relative_to(root).parts
317
+ except ValueError:
318
+ return True
319
+ p = root
320
+ for part in parts:
321
+ p = p / part
322
+ if p.is_symlink():
323
+ return True
324
+ return False
325
+
326
+
327
+ def _read(path: Path) -> bytes | None:
328
+ try:
329
+ if not path.is_file():
330
+ return None
331
+ return path.read_bytes()
332
+ except OSError:
333
+ return None
334
+
335
+
336
+ def _encode(text: str) -> bytes:
337
+ from cli_agent.tools.files import encode_text
338
+
339
+ return encode_text(text)
340
+
341
+
342
+ def _write(path: Path, data: bytes) -> None:
343
+ from cli_agent.tools.files import _atomic_write_bytes
344
+
345
+ _atomic_write_bytes(path, data)