Coverage for src/keel/workspace.py: 100%
70 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""keel runtime workspace: the ``.keel`` directory, its gitignore, and scratch.
3Keel writes runtime artifacts (checkpoints, activity records, the run ledger,
4merge locks) and its agentic steps stage transient scratch (PR diffs, issue
5dumps, draft review/closure prose) while driving a workflow. None of this
6belongs in the consumer's primary checkout: it is keel-owned, disposable
7runtime state. This module owns the one place those artifacts live - the
8project's ``.keel`` directory - and keeps them out of ``git status`` by
9scaffolding a ``.keel/.gitignore`` that ignores the runtime subtrees while
10leaving committed config (``project.yaml``) and extensions tracked.
12It also exposes the **scratch directory** (``.keel/scratch``): the sanctioned
13place for any ad-hoc transient file an agent needs to stage. Routing scratch
14here - instead of the repo root - is why a consumer no longer sees
15``plan.json``, ``pr_<n>.diff``, ``issue.md`` and friends accumulate in their
16checkout.
18Finally it owns **reclamation** of the disposable runtime trees so they do not
19grow without bound: :func:`clean_scratch` empties ``.keel/scratch`` and
20:func:`prune_activity` applies count-based retention to ``.keel/activity``.
21Reclamation is deliberately scoped to those two trees - it never touches the
22run ledger, checkpoint, or locks, which have their own bounded lifecycles and
23(for the ledger) are durable by design.
25Pure-core + thin I/O, mirroring :mod:`keel.checkpoint` and :mod:`keel.activity`:
26:func:`runtime_gitignore_body` is a deterministic pure function; only the
27``ensure_*``, :func:`scratch_dir`, and the reclamation helpers touch the
28filesystem.
29"""
31from __future__ import annotations
33import shutil
34from pathlib import Path
36KEEL_DIRNAME = ".keel"
37GITIGNORE_NAME = ".gitignore"
38SCRATCH_DIRNAME = "scratch"
40# Runtime subtrees of ``.keel`` that are disposable per-run state, expressed as
41# patterns relative to the ``.keel/.gitignore`` that lists them. ``project.yaml``
42# and ``extensions/`` are intentionally absent: they are committed config.
43RUNTIME_IGNORE_ENTRIES: tuple[str, ...] = ("state/", "activity/", "scratch/", "*.tmp")
45_GITIGNORE_HEADER = (
46 "# keel runtime artifacts - generated by `keel`; commit this file.",
47 "# These subtrees are disposable per-run state (checkpoints, activity,",
48 "# locks, agent scratch); keeping them ignored stops keel from polluting",
49 "# your checkout. Delete an entry only if you deliberately track that path.",
50)
53def keel_dir(root: str | Path = ".") -> Path:
54 """Return the project's ``.keel`` directory under ``root`` (not created)."""
55 return Path(root) / KEEL_DIRNAME
58def runtime_gitignore_body() -> str:
59 """Return the canonical ``.keel/.gitignore`` body (deterministic)."""
60 lines = [*_GITIGNORE_HEADER, *RUNTIME_IGNORE_ENTRIES]
61 return "\n".join(lines) + "\n"
64def ensure_runtime_gitignore(keel_directory: str | Path) -> bool:
65 """Scaffold or top up ``<keel_directory>/.gitignore``; idempotent.
67 Creates the gitignore with the canonical body when absent. When it already
68 exists, appends only the runtime entries that are missing - preserving any
69 operator additions and never rewriting an already-complete file. Returns
70 ``True`` when the file was created or changed, ``False`` when it was already
71 a superset of the runtime entries (or the ``.keel`` directory is absent).
72 """
73 directory = Path(keel_directory)
74 if not directory.is_dir():
75 return False
76 gitignore = directory / GITIGNORE_NAME
77 if not gitignore.exists():
78 gitignore.write_text(runtime_gitignore_body(), encoding="utf-8")
79 return True
80 existing = gitignore.read_text(encoding="utf-8")
81 present = {line.strip() for line in existing.splitlines()}
82 missing = [entry for entry in RUNTIME_IGNORE_ENTRIES if entry not in present]
83 if not missing:
84 return False
85 prefix = existing if existing.endswith("\n") or existing == "" else existing + "\n"
86 gitignore.write_text(prefix + "\n".join(missing) + "\n", encoding="utf-8")
87 return True
90def ensure_runtime_gitignore_for(artifact_path: str | Path) -> bool:
91 """Self-heal the gitignore for a runtime artifact about to be written.
93 Walks the artifact's ancestors for a ``.keel`` directory and scaffolds its
94 gitignore. A no-op (returns ``False``) when the artifact lives outside any
95 ``.keel`` tree - e.g. an operator pointed an output path elsewhere on
96 purpose, which keel must not silently ignore.
97 """
98 target = Path(artifact_path)
99 for ancestor in target.parents:
100 if ancestor.name == KEEL_DIRNAME and ancestor.is_dir():
101 return ensure_runtime_gitignore(ancestor)
102 return False
105def scratch_dir(root: str | Path = ".", *, create: bool = True) -> Path:
106 """Return ``.keel/scratch`` - the sanctioned home for transient artifacts.
108 With ``create`` (the default) the directory and the runtime gitignore are
109 materialised so callers can write into it immediately and it never surfaces
110 in ``git status``.
111 """
112 directory = keel_dir(root)
113 scratch = directory / SCRATCH_DIRNAME
114 if create:
115 scratch.mkdir(parents=True, exist_ok=True)
116 ensure_runtime_gitignore(directory)
117 return scratch
120def scratch_entries(root: str | Path = ".") -> list[str]:
121 """Sorted top-level names currently under ``.keel/scratch`` (``[]`` if none)."""
122 scratch = keel_dir(root) / SCRATCH_DIRNAME
123 if not scratch.is_dir():
124 return []
125 return sorted(p.name for p in scratch.iterdir())
128def clean_scratch(root: str | Path = ".") -> list[str]:
129 """Reclaim ``.keel/scratch`` entirely; return the entries that were removed.
131 Scratch is transient by definition, so this empties it wholesale. The
132 directory is recreated on the next :func:`scratch_dir` call. A no-op (``[]``)
133 when scratch does not exist.
134 """
135 entries = scratch_entries(root)
136 scratch = keel_dir(root) / SCRATCH_DIRNAME
137 if scratch.is_dir():
138 shutil.rmtree(scratch)
139 return entries
142def activity_prune_plan(activity_dir: str | Path, *, keep_last: int) -> list[str]:
143 """Names of activity records that exceed ``keep_last`` (oldest first removed).
145 Retention is count-based: the newest ``keep_last`` ``.json`` records (by
146 mtime, then name for a stable tiebreak) are kept; the rest are returned as
147 the prune plan. Only ``.json`` files are considered, so a stray file never
148 counts against - or gets caught by - retention. ``keep_last`` must be
149 non-negative. A no-op (``[]``) when the directory is absent or within budget.
150 """
151 if keep_last < 0:
152 raise ValueError("keep_last must be non-negative")
153 directory = Path(activity_dir)
154 if not directory.is_dir():
155 return []
156 records = [p for p in directory.iterdir() if p.is_file() and p.suffix == ".json"]
157 if len(records) <= keep_last:
158 return []
159 ordered = sorted(records, key=lambda p: (p.stat().st_mtime, p.name), reverse=True)
160 return sorted(p.name for p in ordered[keep_last:])
163def prune_activity(activity_dir: str | Path, *, keep_last: int) -> list[str]:
164 """Apply count-based retention to ``activity_dir``; return the names removed.
166 Removes exactly the records named by :func:`activity_prune_plan`. Never
167 touches non-``.json`` entries, and is only ever pointed at the activity
168 directory - the run ledger, checkpoint, and locks live elsewhere and are
169 out of reach by construction.
170 """
171 directory = Path(activity_dir)
172 doomed = activity_prune_plan(directory, keep_last=keep_last)
173 for name in doomed:
174 (directory / name).unlink()
175 return doomed